From 60834296cc9b1372e5a55fbf1422e6002a8a1aaa Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 13 Aug 2026 10:59:18 +0200 Subject: [PATCH 1/2] Add 'cratis render' as a root command Renders a .play file, or every .play file beneath a folder, into a Cratis application on disk through the Stage renderer. What the document states but the rendered application cannot express goes to standard error rather than being dropped; a document the compiler rejects is not rendered at all. Does not work: the renderer package carries the Arc and Chronicle runtime and the template engine (Cratis/Stage#34), which forces three csproj workarounds here and still leaves the two hosted engines in conflict - MSBuildLocator requires no NuGet.Frameworks beside the executable, the template engine requires one. Committed unmerged so the command survives until that is fixed. --- Directory.Packages.props | 4 + Source/Cli.Specs/Cli.Specs.csproj | 8 + Source/Cli.Specs/Usings.cs | 1 + .../given/a_render_command.cs | 56 +++++++ .../and_an_output_directory_is_given.cs | 21 +++ .../and_no_documents_are_found.cs | 16 ++ ...the_application_cannot_carry_everything.cs | 40 +++++ .../and_the_document_has_an_error.cs | 22 +++ .../and_the_document_renders.cs | 18 +++ .../and_the_path_cannot_be_resolved.cs | 17 +++ Source/Cli/Cli.csproj | 16 ++ .../Commands/Render/IScreenplayRendering.cs | 25 +++ Source/Cli/Commands/Render/RenderCommand.cs | 142 ++++++++++++++++++ Source/Cli/Commands/Render/RenderSettings.cs | 28 ++++ .../Cli/Commands/Render/RenderedScreenplay.cs | 21 +++ .../Commands/Render/ScreenplayRendering.cs | 53 +++++++ .../Screenplay/ScreenplayValidation.cs | 6 +- .../Screenplay/ValidatedScreenplay.cs | 11 +- 18 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 Source/Cli.Specs/for_RenderCommand/given/a_render_command.cs create mode 100644 Source/Cli.Specs/for_RenderCommand/when_rendering/and_an_output_directory_is_given.cs create mode 100644 Source/Cli.Specs/for_RenderCommand/when_rendering/and_no_documents_are_found.cs create mode 100644 Source/Cli.Specs/for_RenderCommand/when_rendering/and_the_application_cannot_carry_everything.cs create mode 100644 Source/Cli.Specs/for_RenderCommand/when_rendering/and_the_document_has_an_error.cs create mode 100644 Source/Cli.Specs/for_RenderCommand/when_rendering/and_the_document_renders.cs create mode 100644 Source/Cli.Specs/for_RenderCommand/when_rendering/and_the_path_cannot_be_resolved.cs create mode 100644 Source/Cli/Commands/Render/IScreenplayRendering.cs create mode 100644 Source/Cli/Commands/Render/RenderCommand.cs create mode 100644 Source/Cli/Commands/Render/RenderSettings.cs create mode 100644 Source/Cli/Commands/Render/RenderedScreenplay.cs create mode 100644 Source/Cli/Commands/Render/ScreenplayRendering.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index b38ba10..ad743e0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + @@ -16,6 +17,9 @@ + + + diff --git a/Source/Cli.Specs/Cli.Specs.csproj b/Source/Cli.Specs/Cli.Specs.csproj index 5940b37..cb9637f 100644 --- a/Source/Cli.Specs/Cli.Specs.csproj +++ b/Source/Cli.Specs/Cli.Specs.csproj @@ -13,6 +13,14 @@ + + + + diff --git a/Source/Cli.Specs/Usings.cs b/Source/Cli.Specs/Usings.cs index 9e3f830..c5cbe4c 100644 --- a/Source/Cli.Specs/Usings.cs +++ b/Source/Cli.Specs/Usings.cs @@ -9,6 +9,7 @@ global using Cratis.Cli.Commands.Init; global using Cratis.Cli.Commands.Llm; global using Cratis.Cli.Commands.Prologue; +global using Cratis.Cli.Commands.Render; global using Cratis.Cli.Commands.Run; global using Cratis.Cli.Commands.Screenplay; global using Cratis.Specifications; diff --git a/Source/Cli.Specs/for_RenderCommand/given/a_render_command.cs b/Source/Cli.Specs/for_RenderCommand/given/a_render_command.cs new file mode 100644 index 0000000..a96555f --- /dev/null +++ b/Source/Cli.Specs/for_RenderCommand/given/a_render_command.cs @@ -0,0 +1,56 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_RenderCommand.given; + +/// +/// Base context that puts a document in a temporary folder and substitutes the rendering. +/// +public class a_render_command : Specification +{ + protected string _folder; + protected string _document; + protected string _previousDirectory; + protected IScreenplayRendering _rendering; + protected RenderCommand _command; + protected RenderSettings _settings; + + void Establish() + { + var created = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())).FullName; + _previousDirectory = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(created); + + // Read the folder back so that specs compare against the same fully resolved path the command sees — + // the temp folder is reached through a symbolic link on macOS. + _folder = Directory.GetCurrentDirectory(); + _document = Path.Combine(_folder, "MyApp.play"); + File.WriteAllText(_document, "domain Library\n"); + + _rendering = Substitute.For(); + _rendering.Render(Arg.Any(), Arg.Any()).Returns(new RenderedScreenplay(1, [], [])); + + _command = new RenderCommand(_rendering); + _settings = new RenderSettings { Output = OutputFormats.JsonCompact }; + } + + /// + /// Executes the command with the established settings. + /// + /// The exit code. + protected Task Execute() => + ((ICommand)_command).ExecuteAsync( + new CommandContext([], Substitute.For(), "render", null), + _settings, + CancellationToken.None); + + void Destroy() + { + Directory.SetCurrentDirectory(_previousDirectory); + + if (Directory.Exists(_folder)) + { + Directory.Delete(_folder, true); + } + } +} diff --git a/Source/Cli.Specs/for_RenderCommand/when_rendering/and_an_output_directory_is_given.cs b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_an_output_directory_is_given.cs new file mode 100644 index 0000000..a9ed121 --- /dev/null +++ b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_an_output_directory_is_given.cs @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_RenderCommand.when_rendering; + +[Collection(CliSpecsCollection.Name)] +public class and_an_output_directory_is_given : given.a_render_command +{ + int _result; + + void Establish() + { + _settings.Path = "MyApp.play"; + _settings.Target = "src/MyApp"; + } + + async Task Because() => _result = await Execute(); + + [Fact] void should_succeed() => _result.ShouldEqual(ExitCodes.Success); + [Fact] void should_render_into_it() => _rendering.Received(1).Render(_document, Path.Combine(_folder, "src", "MyApp")); +} diff --git a/Source/Cli.Specs/for_RenderCommand/when_rendering/and_no_documents_are_found.cs b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_no_documents_are_found.cs new file mode 100644 index 0000000..722bc74 --- /dev/null +++ b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_no_documents_are_found.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_RenderCommand.when_rendering; + +[Collection(CliSpecsCollection.Name)] +public class and_no_documents_are_found : given.a_render_command +{ + int _result; + + void Establish() => _rendering.Render(Arg.Any(), Arg.Any()).Returns(new RenderedScreenplay(0, [], [])); + + async Task Because() => _result = await Execute(); + + [Fact] void should_report_that_nothing_was_found() => _result.ShouldEqual(ExitCodes.NotFound); +} diff --git a/Source/Cli.Specs/for_RenderCommand/when_rendering/and_the_application_cannot_carry_everything.cs b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_the_application_cannot_carry_everything.cs new file mode 100644 index 0000000..9172aa5 --- /dev/null +++ b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_the_application_cannot_carry_everything.cs @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_RenderCommand.when_rendering; + +/// +/// A Screenplay document states more than any one target can express. What does not survive the crossing is +/// reported rather than dropped silently — and it is not a failure, so the command still succeeds. +/// +[Collection(CliSpecsCollection.Name)] +public class and_the_application_cannot_carry_everything : given.a_render_command +{ + int _result; + string _error; + TextWriter _previousError; + StringWriter _capturedError; + + void Establish() + { + _previousError = Console.Error; + _capturedError = new StringWriter(); + Console.SetError(_capturedError); + + _rendering + .Render(Arg.Any(), Arg.Any()) + .Returns(new RenderedScreenplay(1, [], ["Slice 'Register' declares 2 screen declaration(s) with no rendered equivalent"])); + } + + async Task Because() + { + _result = await Execute(); + _error = _capturedError.ToString(); + } + + [Fact] void should_succeed() => _result.ShouldEqual(ExitCodes.Success); + [Fact] void should_say_what_the_rendered_application_does_not_carry() => + _error.ShouldContain("Slice 'Register' declares 2 screen declaration(s) with no rendered equivalent"); + + void Destroy() => Console.SetError(_previousError); +} diff --git a/Source/Cli.Specs/for_RenderCommand/when_rendering/and_the_document_has_an_error.cs b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_the_document_has_an_error.cs new file mode 100644 index 0000000..bab8293 --- /dev/null +++ b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_the_document_has_an_error.cs @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_RenderCommand.when_rendering; + +[Collection(CliSpecsCollection.Name)] +public class and_the_document_has_an_error : given.a_render_command +{ + int _result; + + void Establish() => + _rendering + .Render(Arg.Any(), Arg.Any()) + .Returns(new RenderedScreenplay( + 1, + [new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, "PLAY0001", "an error", "MyApp.play(3,1)")], + [])); + + async Task Because() => _result = await Execute(); + + [Fact] void should_report_a_validation_error() => _result.ShouldEqual(ExitCodes.ValidationError); +} diff --git a/Source/Cli.Specs/for_RenderCommand/when_rendering/and_the_document_renders.cs b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_the_document_renders.cs new file mode 100644 index 0000000..1d147ed --- /dev/null +++ b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_the_document_renders.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_RenderCommand.when_rendering; + +[Collection(CliSpecsCollection.Name)] +public class and_the_document_renders : given.a_render_command +{ + int _result; + + void Establish() => _settings.Path = "MyApp.play"; + + async Task Because() => _result = await Execute(); + + [Fact] void should_succeed() => _result.ShouldEqual(ExitCodes.Success); + [Fact] void should_render_the_resolved_document() => + _rendering.Received(1).Render(_document, Path.Combine(_folder, RenderCommand.DefaultTarget)); +} diff --git a/Source/Cli.Specs/for_RenderCommand/when_rendering/and_the_path_cannot_be_resolved.cs b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_the_path_cannot_be_resolved.cs new file mode 100644 index 0000000..e4ec5ab --- /dev/null +++ b/Source/Cli.Specs/for_RenderCommand/when_rendering/and_the_path_cannot_be_resolved.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Cli.for_RenderCommand.when_rendering; + +[Collection(CliSpecsCollection.Name)] +public class and_the_path_cannot_be_resolved : given.a_render_command +{ + int _result; + + void Establish() => _settings.Path = "Missing/MyApp.play"; + + async Task Because() => _result = await Execute(); + + [Fact] void should_report_that_it_was_not_found() => _result.ShouldEqual(ExitCodes.NotFound); + [Fact] void should_not_render_anything() => _rendering.DidNotReceive().Render(Arg.Any(), Arg.Any()); +} diff --git a/Source/Cli/Cli.csproj b/Source/Cli/Cli.csproj index 33ec078..93ba5b2 100644 --- a/Source/Cli/Cli.csproj +++ b/Source/Cli/Cli.csproj @@ -39,6 +39,16 @@ + + + + + @@ -53,6 +63,12 @@ + + - @@ -17,9 +16,8 @@ - - - + + diff --git a/Source/Cli.Specs/Cli.Specs.csproj b/Source/Cli.Specs/Cli.Specs.csproj index cb9637f..147ca70 100644 --- a/Source/Cli.Specs/Cli.Specs.csproj +++ b/Source/Cli.Specs/Cli.Specs.csproj @@ -13,12 +13,6 @@ - - diff --git a/Source/Cli/Cli.csproj b/Source/Cli/Cli.csproj index 93ba5b2..b9f5894 100644 --- a/Source/Cli/Cli.csproj +++ b/Source/Cli/Cli.csproj @@ -39,13 +39,6 @@ - - @@ -63,12 +56,6 @@ - -