diff --git a/Directory.Packages.props b/Directory.Packages.props index 2e0b32d..9d5d675 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,16 +5,17 @@ + - + - + @@ -27,6 +28,12 @@ + + + + + + diff --git a/Documentation/reference/index.md b/Documentation/reference/index.md index 1d295b7..b3ad952 100644 --- a/Documentation/reference/index.md +++ b/Documentation/reference/index.md @@ -6,6 +6,7 @@ This section documents the cross-cutting mechanics of the `cratis` CLI: the flag - [LLM](llm.md) — Configuring the language model that Cratis tools like Prologue use. - [Prologue](prologue.md) — Capturing a running system and interpreting the captures into a Screenplay. +- [Screenplay](screenplay.md) — Generating a Screenplay from the source code of a Cratis Arc application, and validating the documents you have. - [Global Options](global-options.md) — Flags such as `--output`, `--quiet`, `--yes`, and `--debug` that apply to every command. - [Output Formats](output-formats.md) — The four output formats (`table`, `plain`, `json`, `json-compact`) with guidance on when to use each and token cost comparisons. - [Connection](connection.md) — Connection string format, resolution order, and environment variable configuration. diff --git a/Documentation/reference/screenplay.md b/Documentation/reference/screenplay.md new file mode 100644 index 0000000..8c7c324 --- /dev/null +++ b/Documentation/reference/screenplay.md @@ -0,0 +1,145 @@ +# Screenplay + +`cratis screenplay` works with Cratis Screenplay (`.play`) documents. It generates one from the source code of a Cratis Arc application — so the event model your team reads is derived from the code that actually runs rather than maintained alongside it — and it compiles the documents you already have. + +```bash +cratis screenplay generate [PATH] +cratis screenplay validate [PATH] +``` + +**Nothing needs to be running.** This is what separates `cratis screenplay` from [`cratis arc`](../arc/index.md): every `arc` command talks to a *running* application over HTTP, while `screenplay` only ever reads files. The result is reproducible from a checkout — commit it, diff it, and run it in CI, on a machine where the application was never started. + +Fetching a `.play` document from a running Arc application over its introspection endpoint is a separate, complementary route: it trades the SDK requirement for the requirement that the application be running. That route does not exist yet — neither the Arc endpoint nor a CLI command for it — so generating from source is today the only way to derive a Screenplay from a Cratis Arc application. + +## `cratis screenplay generate [PATH]` + +Reads a solution or project, derives the event model from the Arc artifacts it finds — commands, events, read models, projections, reactors, constraints, and the concepts they are built from — and writes a Screenplay document. + +By default the document goes to standard output, so it composes with the shell: + +```bash +cratis screenplay generate > MyApp.play +``` + +Pass `--file` to write it directly instead. The output is written as raw UTF-8, byte for byte, ending in exactly one newline — regenerating an unchanged model produces an identical file. + +### Arguments + +| Argument | Description | +|---|---| +| `PATH` | Solution (`.slnx`, `.sln`), project (`.csproj`), or folder to read. Defaults to the current directory. | + +### Options + +| Option | Description | +|---|---| +| `--file ` | File to write the generated Screenplay to. Writes to standard output when not given. | +| `--domain ` | Name of the domain the generated document belongs to. Defaults to the assembly or root namespace of the project. | +| `--module ` | Name of the module every discovered feature is placed within. Defaults to the domain. | +| `--skip-segments ` | Number of leading namespace segments to skip when inferring features and slices. | + +The output file uses `--file` rather than `-o`, because `-o/--output` is the global output *format* flag — see [Global Options](global-options.md). + +```bash +cratis screenplay generate +cratis screenplay generate ./MyApp.slnx --file MyApp.play +cratis screenplay generate ./Source/MyApp/MyApp.csproj +cratis screenplay generate --domain Library --module Lending --file Library.play +``` + +### Finding the solution or project + +When `PATH` is a solution or project file, that file is read. When it is a folder — or is omitted entirely — the CLI looks in that folder and then in each parent folder in turn, stopping at the first one that holds a match. Within a folder it prefers `.slnx`, then `.sln`, then `.csproj`. Two candidates of the same kind in one folder is reported rather than guessed at. + +A Screenplay describes one application, so a solution has to narrow down to a single project: + +1. Projects whose name ends in `.Specs`, `.Specifications`, `.Tests`, `.Test`, or `.IntegrationTests` are dropped. +2. If any project that remains produces an executable, the libraries are dropped — an Arc application is the executable. +3. Exactly one project must remain. Anything else is reported, listing the candidates, so you can pass the project you meant. + +### Diagnostics + +Anything the generator cannot express in Screenplay is reported rather than silently dropped — a projection operator with no counterpart, a validator rule that has no equivalent, a construct only available as compiled metadata because it lives in a referenced package. + +Diagnostics always go to **standard error**, grouped by severity with errors first, so redirecting standard output to a `.play` file never mixes them in: + +```text +errors (1): + error SP0203: [Library.Lending.Reserving] projection uses $combine, which has no Screenplay counterpart + +warnings (2): + warning SP0110: [Library.Authors] command handler body is not available in this compilation + warning SP0141: [Library.Authors.Registration] validator rule Must() cannot be expressed +``` + +With `-o json` or `-o json-compact` the same diagnostics are written to standard error as a JSON object instead. + +**Warnings and information do not fail the command** — the document is still written. **An error does**: nothing is written and the command exits with a validation error, because a document that does not describe the source faithfully is worse than no document. + +### Prerequisites + +The command loads the project through MSBuild, so the **.NET SDK** must be installed — the same SDK you build the project with. Packages must be restorable; a project that cannot be restored cannot be read. + +The project does **not** have to have been built first. Sources MSBuild generates as part of a build — such as the strongly typed classes a `.resx` file declares with `MSBuild:Compile` — are produced while the project is read, so the model is derived from exactly what a real build compiles. + +### Errors + +| Condition | Result | +|---|---| +| `PATH` does not exist | Not-found error. | +| `PATH` is a file that is not a solution or project | Not-found error. | +| No solution or project found in `PATH` or any parent folder | Not-found error. | +| The solution holds more than one candidate project | Validation error listing the candidates. | +| Generation reports one or more errors, with `--file` | Validation error; the document is written anyway. | +| Generation reports one or more errors, writing to standard output | Validation error; nothing is written. | + +An error means the document does not describe the source faithfully — but a document that is 99% right plus honest diagnostics is more useful than nothing at all, so `--file` still writes it. Read the diagnostics before trusting it, and re-run with `screenplay validate` to see what the Screenplay compiler makes of the result. + +Standard output is the exception: whatever consumes `cratis screenplay generate > MyApp.play` cannot tell a partial document from a complete one, so nothing is written there. Pass `--file` when you want the partial document. + +## `cratis screenplay validate [PATH]` + +Compiles Screenplay documents and reports everything the compiler found. It does not care what wrote them — `screenplay generate`, [`cratis prologue`](prologue.md), or a person designing a system before any code exists. + +`PATH` is a Screenplay (`.play`) file, or a folder — in which case every `.play` file beneath it is compiled. It defaults to the current directory. + +```bash +cratis screenplay validate # every .play file beneath the current folder +cratis screenplay validate ./MyApp.play # one document +cratis screenplay validate ./plays # every .play file beneath a folder +``` + +### Compiler diagnostics + +Diagnostics go to **standard error**, grouped by severity with errors first, in the same shape `generate` uses. The compiler does not assign codes, so each line carries the file and the position within it instead: + +```text +errors (1): + error: [MyApp.play(5,5)] Invalid slice declaration 'slice Reserving' - expected 'slice ' + +warnings (1): + warning: [MyApp.play(787,11)] Unknown event 'InvitationToJoinAdaAccepted' - declare it with 'event InvitationToJoinAdaAccepted' +``` + +With `-o json` or `-o json-compact` the same diagnostics are written to standard error as a JSON object instead. + +**Warnings and information do not fail the command. An error does** — which is what makes this usable as a CI gate on a committed `.play` file. + +### Validation outcomes + +| Condition | Result | +|---|---| +| `PATH` does not exist | Not-found error. | +| `PATH` is a file that is not a `.play` file | Not-found error. | +| No `.play` file found in the folder | Not-found error — validating nothing is never the answer you wanted. | +| Compilation reports one or more errors | Validation error. | + +## Where a Screenplay comes from + +Generating from source is one of three ways to arrive at a `.play` file, and they meet in the same place: + +- **From source** — `screenplay generate`, for an application that already exists in Cratis Arc. Needs the .NET SDK and a checkout; needs nothing running. +- **From a running system** — [`cratis prologue`](prologue.md) captures what a system does and interprets it into a Screenplay, for systems built without Cratis. +- **By hand** — write the `.play` file as the design, before any code exists. + +Whichever route you take, `screenplay validate` compiles the result and [`cratis run`](run.md) boots it in a local Stage sandbox. diff --git a/Documentation/reference/toc.yml b/Documentation/reference/toc.yml index ccb00da..e8a58d7 100644 --- a/Documentation/reference/toc.yml +++ b/Documentation/reference/toc.yml @@ -4,6 +4,8 @@ href: llm.md - name: Prologue href: prologue.md +- name: Screenplay + href: screenplay.md - name: Global Options href: global-options.md - name: Output Formats diff --git a/Source/Cli.Specs/Usings.cs b/Source/Cli.Specs/Usings.cs index a13ae28..9e3f830 100644 --- a/Source/Cli.Specs/Usings.cs +++ b/Source/Cli.Specs/Usings.cs @@ -10,5 +10,8 @@ global using Cratis.Cli.Commands.Llm; global using Cratis.Cli.Commands.Prologue; global using Cratis.Cli.Commands.Run; +global using Cratis.Cli.Commands.Screenplay; global using Cratis.Specifications; +global using NSubstitute; +global using Spectre.Console.Cli; global using Xunit; diff --git a/Source/Cli.Specs/for_DesignTimeResourceGeneration/given/a_temporary_folder.cs b/Source/Cli.Specs/for_DesignTimeResourceGeneration/given/a_temporary_folder.cs new file mode 100644 index 0000000..eeac957 --- /dev/null +++ b/Source/Cli.Specs/for_DesignTimeResourceGeneration/given/a_temporary_folder.cs @@ -0,0 +1,19 @@ +// 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_DesignTimeResourceGeneration.given; + +public class a_temporary_folder : Specification +{ + protected string _folder; + + void Establish() => _folder = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())).FullName; + + void Destroy() + { + if (Directory.Exists(_folder)) + { + Directory.Delete(_folder, true); + } + } +} diff --git a/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_an_existing_file_is_chained.cs b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_an_existing_file_is_chained.cs new file mode 100644 index 0000000..f394348 --- /dev/null +++ b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_an_existing_file_is_chained.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_DesignTimeResourceGeneration.when_building_the_targets_file; + +public class and_an_existing_file_is_chained : given.a_temporary_folder +{ + string _chained; + string _result; + + void Establish() + { + _chained = Path.Combine(_folder, "Custom.After.targets"); + File.WriteAllText(_chained, ""); + } + + void Because() => _result = DesignTimeResourceGeneration.Targets(_chained); + + [Fact] void should_keep_importing_it() => _result.ShouldContain($""); + [Fact] void should_still_run_the_resource_target_chain() => _result.ShouldContain("DependsOnTargets=\"PrepareResources\""); +} diff --git a/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_nothing_is_chained.cs b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_nothing_is_chained.cs new file mode 100644 index 0000000..388d46c --- /dev/null +++ b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_nothing_is_chained.cs @@ -0,0 +1,15 @@ +// 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_DesignTimeResourceGeneration.when_building_the_targets_file; + +public class and_nothing_is_chained : Specification +{ + string _result; + + void Because() => _result = DesignTimeResourceGeneration.Targets(null); + + [Fact] void should_run_the_resource_target_chain() => _result.ShouldContain("DependsOnTargets=\"PrepareResources\""); + [Fact] void should_run_it_before_the_compiler_inputs_are_gathered() => _result.ShouldContain("BeforeTargets=\"CoreCompile\""); + [Fact] void should_not_import_anything() => _result.ShouldNotContain(" _result = DesignTimeResourceGeneration.Targets(Path.Combine(_folder, "Missing.targets")); + + [Fact] void should_not_import_it() => _result.ShouldNotContain(" _result.ShouldContain("DependsOnTargets=\"PrepareResources\""); +} diff --git a/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_the_chained_path_holds_markup_characters.cs b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_the_chained_path_holds_markup_characters.cs new file mode 100644 index 0000000..047c482 --- /dev/null +++ b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_building_the_targets_file/and_the_chained_path_holds_markup_characters.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_DesignTimeResourceGeneration.when_building_the_targets_file; + +public class and_the_chained_path_holds_markup_characters : given.a_temporary_folder +{ + string _chained; + string _result; + + void Establish() + { + _chained = Path.Combine(_folder, "Custom & After.targets"); + File.WriteAllText(_chained, ""); + } + + void Because() => _result = DesignTimeResourceGeneration.Targets(_chained); + + [Fact] void should_escape_them() => _result.ShouldContain("Custom & After.targets"); + [Fact] void should_leave_the_file_readable_as_markup() => _result.ShouldNotContain("& After"); +} diff --git a/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_creating.cs b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_creating.cs new file mode 100644 index 0000000..93dd4be --- /dev/null +++ b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_creating.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_DesignTimeResourceGeneration; + +public class when_creating : Specification +{ + DesignTimeResourceGeneration _generation; + string _file; + + void Because() + { + _generation = DesignTimeResourceGeneration.Create(); + _file = _generation.GlobalProperties[DesignTimeResourceGeneration.HookProperty]; + } + + [Fact] void should_hook_the_targets_file_into_every_project() => DesignTimeResourceGeneration.HookProperty.ShouldEqual("CustomAfterMicrosoftCommonTargets"); + [Fact] void should_write_the_targets_file() => File.Exists(_file).ShouldBeTrue(); + [Fact] void should_write_the_resource_target_chain_into_it() => File.ReadAllText(_file).ShouldContain("PrepareResources"); + + void Destroy() => _generation.Dispose(); +} diff --git a/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_disposing/and_it_has_already_been_disposed.cs b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_disposing/and_it_has_already_been_disposed.cs new file mode 100644 index 0000000..5e5232d --- /dev/null +++ b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_disposing/and_it_has_already_been_disposed.cs @@ -0,0 +1,20 @@ +// 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_DesignTimeResourceGeneration.when_disposing; + +public class and_it_has_already_been_disposed : Specification +{ + DesignTimeResourceGeneration _generation; + Exception _error; + + void Establish() + { + _generation = DesignTimeResourceGeneration.Create(); + _generation.Dispose(); + } + + void Because() => _error = Catch.Exception(_generation.Dispose); + + [Fact] void should_not_fail() => _error.ShouldBeNull(); +} diff --git a/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_disposing/and_the_targets_file_was_written.cs b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_disposing/and_the_targets_file_was_written.cs new file mode 100644 index 0000000..c10beac --- /dev/null +++ b/Source/Cli.Specs/for_DesignTimeResourceGeneration/when_disposing/and_the_targets_file_was_written.cs @@ -0,0 +1,20 @@ +// 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_DesignTimeResourceGeneration.when_disposing; + +public class and_the_targets_file_was_written : Specification +{ + DesignTimeResourceGeneration _generation; + string _file; + + void Establish() + { + _generation = DesignTimeResourceGeneration.Create(); + _file = _generation.GlobalProperties[DesignTimeResourceGeneration.HookProperty]; + } + + void Because() => _generation.Dispose(); + + [Fact] void should_remove_the_targets_file() => File.Exists(_file).ShouldBeFalse(); +} diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/given/a_generate_screenplay_command.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/given/a_generate_screenplay_command.cs new file mode 100644 index 0000000..49468f2 --- /dev/null +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/given/a_generate_screenplay_command.cs @@ -0,0 +1,64 @@ +// 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_GenerateScreenplayCommand.given; + +/// +/// Base context that puts a solution in a temporary folder, substitutes the generation, and captures what the +/// command writes to standard output. +/// +public class a_generate_screenplay_command : Specification +{ + protected const string GeneratedSource = "domain Library\n\nmodule Library\n"; + + protected string _folder; + protected string _solution; + protected string _previousDirectory; + protected IScreenplayGeneration _generation; + protected MemoryStream _standardOutput; + protected GenerateScreenplayCommand _command; + protected GenerateScreenplaySettings _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(); + _solution = Path.Combine(_folder, "MyApp.slnx"); + File.WriteAllText(_solution, ""); + + _generation = Substitute.For(); + _generation + .Generate(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new GeneratedScreenplay(GeneratedSource, []))); + + _standardOutput = new MemoryStream(); + _command = new GenerateScreenplayCommand(_generation, () => _standardOutput); + _settings = new GenerateScreenplaySettings { Output = OutputFormats.JsonCompact }; + } + + /// + /// Executes the command with the established settings. + /// + /// The exit code. + protected Task Execute() => + ((ICommand)_command).ExecuteAsync( + new CommandContext([], Substitute.For(), "generate", null), + _settings, + CancellationToken.None); + + void Destroy() + { + Directory.SetCurrentDirectory(_previousDirectory); + _standardOutput.Dispose(); + + if (Directory.Exists(_folder)) + { + Directory.Delete(_folder, true); + } + } +} diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_a_file_is_given.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_a_file_is_given.cs new file mode 100644 index 0000000..fc26d8a --- /dev/null +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_a_file_is_given.cs @@ -0,0 +1,26 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; + +namespace Cratis.Cli.for_GenerateScreenplayCommand.when_generating; + +[Collection(CliSpecsCollection.Name)] +public class and_a_file_is_given : given.a_generate_screenplay_command +{ + string _outputPath; + int _result; + + void Establish() + { + _outputPath = Path.Combine(_folder, "plays", "MyApp.play"); + _settings.File = Path.Combine("plays", "MyApp.play"); + } + + async Task Because() => _result = await Execute(); + + [Fact] void should_succeed() => _result.ShouldEqual(ExitCodes.Success); + [Fact] void should_write_the_document_to_the_file() => File.ReadAllBytes(_outputPath).ShouldEqual(Encoding.UTF8.GetBytes(GeneratedSource)); + [Fact] void should_create_the_folder_the_file_lives_in() => Directory.Exists(Path.GetDirectoryName(_outputPath)).ShouldBeTrue(); + [Fact] void should_not_write_the_document_to_standard_output() => _standardOutput.ToArray().ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_options_are_given.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_options_are_given.cs new file mode 100644 index 0000000..60f5568 --- /dev/null +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_options_are_given.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_GenerateScreenplayCommand.when_generating; + +[Collection(CliSpecsCollection.Name)] +public class and_generation_options_are_given : given.a_generate_screenplay_command +{ + void Establish() + { + _settings.Domain = "Library"; + _settings.Module = "Lending"; + _settings.SkipSegments = 2; + } + + async Task Because() => await Execute(); + + [Fact] void should_pass_them_to_the_generation() => _generation.Received(1).Generate( + Arg.Any(), + Arg.Is(options => options.Domain == "Library" && options.Module == "Lending" && options.SegmentsToSkip == 2), + Arg.Any()); +} diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/and_a_file_is_given.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/and_a_file_is_given.cs new file mode 100644 index 0000000..e011c4f --- /dev/null +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/and_a_file_is_given.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; + +namespace Cratis.Cli.for_GenerateScreenplayCommand.when_generating.and_generation_reports_an_error; + +[Collection(CliSpecsCollection.Name)] +public class and_a_file_is_given : given.a_generation_reporting_an_error +{ + string _outputPath; + int _result; + + void Establish() + { + _outputPath = Path.Combine(_folder, "MyApp.play"); + _settings.File = "MyApp.play"; + } + + async Task Because() => _result = await Execute(); + + [Fact] void should_fail_with_a_validation_error() => _result.ShouldEqual(ExitCodes.ValidationError); + [Fact] void should_still_write_the_document_to_the_file() => File.ReadAllBytes(_outputPath).ShouldEqual(Encoding.UTF8.GetBytes(GeneratedSource)); + [Fact] void should_not_write_the_document_to_standard_output() => _standardOutput.ToArray().ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/and_no_file_is_given.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/and_no_file_is_given.cs new file mode 100644 index 0000000..8e93bc2 --- /dev/null +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/and_no_file_is_given.cs @@ -0,0 +1,15 @@ +// 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_GenerateScreenplayCommand.when_generating.and_generation_reports_an_error; + +[Collection(CliSpecsCollection.Name)] +public class and_no_file_is_given : given.a_generation_reporting_an_error +{ + int _result; + + async Task Because() => _result = await Execute(); + + [Fact] void should_fail_with_a_validation_error() => _result.ShouldEqual(ExitCodes.ValidationError); + [Fact] void should_keep_standard_output_clean() => _standardOutput.ToArray().ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/given/a_generation_reporting_an_error.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/given/a_generation_reporting_an_error.cs new file mode 100644 index 0000000..0bf5151 --- /dev/null +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_an_error/given/a_generation_reporting_an_error.cs @@ -0,0 +1,20 @@ +// 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_GenerateScreenplayCommand.when_generating.and_generation_reports_an_error.given; + +/// +/// A generation that produced a document and reported an error alongside it. +/// +public class a_generation_reporting_an_error : for_GenerateScreenplayCommand.given.a_generate_screenplay_command +{ + void Establish() => + _generation + .Generate(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new GeneratedScreenplay( + GeneratedSource, + [ + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, "SP0100", "a warning", null), + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, "SP0200", "an error", "Library.Lending") + ]))); +} diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_only_warnings.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_only_warnings.cs new file mode 100644 index 0000000..997761b --- /dev/null +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_generation_reports_only_warnings.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; + +namespace Cratis.Cli.for_GenerateScreenplayCommand.when_generating; + +[Collection(CliSpecsCollection.Name)] +public class and_generation_reports_only_warnings : given.a_generate_screenplay_command +{ + int _result; + + void Establish() => + _generation + .Generate(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new GeneratedScreenplay( + GeneratedSource, + [new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, "SP0100", "a warning", null)]))); + + async Task Because() => _result = await Execute(); + + [Fact] void should_succeed() => _result.ShouldEqual(ExitCodes.Success); + [Fact] void should_still_write_the_document() => _standardOutput.ToArray().ShouldEqual(Encoding.UTF8.GetBytes(GeneratedSource)); +} diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_no_file_is_given.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_no_file_is_given.cs new file mode 100644 index 0000000..3f83347 --- /dev/null +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_no_file_is_given.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; + +namespace Cratis.Cli.for_GenerateScreenplayCommand.when_generating; + +[Collection(CliSpecsCollection.Name)] +public class and_no_file_is_given : given.a_generate_screenplay_command +{ + int _result; + + async Task Because() => _result = await Execute(); + + [Fact] void should_succeed() => _result.ShouldEqual(ExitCodes.Success); + [Fact] void should_write_the_document_to_standard_output() => _standardOutput.ToArray().ShouldEqual(Encoding.UTF8.GetBytes(GeneratedSource)); + [Fact] void should_generate_from_the_discovered_solution() => _generation.Received(1).Generate(_solution, Arg.Any(), Arg.Any()); + [Fact] void should_not_write_a_file() => Directory.GetFiles(_folder, "*.play").ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_the_path_cannot_be_resolved.cs b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_the_path_cannot_be_resolved.cs new file mode 100644 index 0000000..6cb0458 --- /dev/null +++ b/Source/Cli.Specs/for_GenerateScreenplayCommand/when_generating/and_the_path_cannot_be_resolved.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_GenerateScreenplayCommand.when_generating; + +[Collection(CliSpecsCollection.Name)] +public class and_the_path_cannot_be_resolved : given.a_generate_screenplay_command +{ + int _result; + + void Establish() => _settings.Path = Path.Combine(_folder, "does-not-exist"); + + async Task Because() => _result = await Execute(); + + [Fact] void should_report_that_nothing_was_found() => _result.ShouldEqual(ExitCodes.NotFound); + [Fact] void should_not_generate() => _generation.DidNotReceive().Generate(Arg.Any(), Arg.Any(), Arg.Any()); + [Fact] void should_not_write_anything_to_standard_output() => _standardOutput.ToArray().ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_GeneratedResourceSources/given/a_loaded_project.cs b/Source/Cli.Specs/for_GeneratedResourceSources/given/a_loaded_project.cs new file mode 100644 index 0000000..aa9779b --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedResourceSources/given/a_loaded_project.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Cratis.Cli.for_GeneratedResourceSources.given; + +/// +/// Base context adding a project that compiles into the intermediate output folder, standing in for the project an +/// MSBuild workspace hands over. +/// +public class a_loaded_project : an_intermediate_output_folder +{ + protected AdhocWorkspace _workspace; + protected Project _project; + + void Establish() + { + _workspace = new AdhocWorkspace(); + _project = _workspace.AddProject( + ProjectInfo + .Create(ProjectId.CreateNewId(), VersionStamp.Create(), "MyApp", "MyApp", LanguageNames.CSharp) + .WithCompilationOutputInfo(default(CompilationOutputInfo).WithAssemblyPath(_assemblyPath))); + } + + void Destroy() => _workspace.Dispose(); +} diff --git a/Source/Cli.Specs/for_GeneratedResourceSources/given/an_intermediate_output_folder.cs b/Source/Cli.Specs/for_GeneratedResourceSources/given/an_intermediate_output_folder.cs new file mode 100644 index 0000000..8cab952 --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedResourceSources/given/an_intermediate_output_folder.cs @@ -0,0 +1,43 @@ +// 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_GeneratedResourceSources.given; + +/// +/// Base context holding an intermediate output folder as a build leaves it — the assembly the project compiles +/// into, the strongly typed resource sources generated next to it, and a generated source that is not one of them. +/// +public class an_intermediate_output_folder : Specification +{ + protected string _folder; + protected string _intermediate; + protected string _assemblyPath; + protected string _commonMessages; + protected string _adminMessages; + + void Establish() + { + _folder = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())).FullName; + _intermediate = Directory.CreateDirectory(Path.Combine(_folder, "obj", "Debug", "net10.0")).FullName; + _assemblyPath = Path.Combine(_intermediate, "MyApp.dll"); + + _adminMessages = Write("AdminMessages"); + _commonMessages = Write("CommonMessages"); + File.WriteAllText(Path.Combine(_intermediate, "MyApp.AssemblyInfo.cs"), "// Not a resource class"); + } + + void Destroy() + { + if (Directory.Exists(_folder)) + { + Directory.Delete(_folder, true); + } + } + + string Write(string name) + { + var file = Path.Combine(_intermediate, $"{name}.Designer.cs"); + File.WriteAllText(file, $"namespace MyApp.Resources;\n\npublic static class {name}\n{{\n}}\n"); + return file; + } +} diff --git a/Source/Cli.Specs/for_GeneratedResourceSources/when_adding_missing_sources/and_the_project_already_compiles_them.cs b/Source/Cli.Specs/for_GeneratedResourceSources/when_adding_missing_sources/and_the_project_already_compiles_them.cs new file mode 100644 index 0000000..961df50 --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedResourceSources/when_adding_missing_sources/and_the_project_already_compiles_them.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Cratis.Cli.for_GeneratedResourceSources.when_adding_missing_sources; + +public class and_the_project_already_compiles_them : given.a_loaded_project +{ + Project _result; + + void Establish() + { + var solution = _project.Solution; + foreach (var file in new[] { _adminMessages, _commonMessages }) + { + solution = solution.AddDocument( + DocumentId.CreateNewId(_project.Id), + Path.GetFileName(file), + SourceText.From(File.ReadAllText(file)), + filePath: file); + } + + _project = solution.GetProject(_project.Id); + } + + void Because() => _result = GeneratedResourceSources.AddMissingTo(_project); + + [Fact] void should_not_add_them_a_second_time() => _result.Documents.Count().ShouldEqual(2); +} diff --git a/Source/Cli.Specs/for_GeneratedResourceSources/when_adding_missing_sources/and_the_project_does_not_compile_them.cs b/Source/Cli.Specs/for_GeneratedResourceSources/when_adding_missing_sources/and_the_project_does_not_compile_them.cs new file mode 100644 index 0000000..8a75433 --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedResourceSources/when_adding_missing_sources/and_the_project_does_not_compile_them.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. + +using Microsoft.CodeAnalysis; + +namespace Cratis.Cli.for_GeneratedResourceSources.when_adding_missing_sources; + +public class and_the_project_does_not_compile_them : given.a_loaded_project +{ + Project _result; + + void Because() => _result = GeneratedResourceSources.AddMissingTo(_project); + + [Fact] void should_add_every_generated_resource_source() => _result.Documents.Count().ShouldEqual(2); + [Fact] void should_add_the_admin_messages() => _result.Documents.Select(document => document.FilePath).ShouldContain(_adminMessages); + [Fact] void should_add_the_common_messages() => _result.Documents.Select(document => document.FilePath).ShouldContain(_commonMessages); + [Fact] async Task should_read_the_source_back_from_disk() => (await _result.Documents.First().GetTextAsync()).ToString().ShouldContain("public static class"); +} diff --git a/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_none_of_them_are_compiled.cs b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_none_of_them_are_compiled.cs new file mode 100644 index 0000000..5d45b13 --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_none_of_them_are_compiled.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_GeneratedResourceSources.when_finding_missing_sources; + +public class and_none_of_them_are_compiled : given.an_intermediate_output_folder +{ + IReadOnlyList _result; + + void Because() => _result = GeneratedResourceSources.MissingFrom(_assemblyPath, []); + + [Fact] void should_find_every_generated_resource_source() => _result.Count.ShouldEqual(2); + [Fact] void should_find_them_in_a_stable_order() => _result[0].ShouldEqual(_adminMessages); + [Fact] void should_find_the_second_one_too() => _result[1].ShouldEqual(_commonMessages); + [Fact] void should_leave_other_generated_sources_alone() => _result.ShouldNotContain(Path.Combine(_intermediate, "MyApp.AssemblyInfo.cs")); +} diff --git a/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_one_of_them_is_compiled.cs b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_one_of_them_is_compiled.cs new file mode 100644 index 0000000..7936549 --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_one_of_them_is_compiled.cs @@ -0,0 +1,13 @@ +// 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_GeneratedResourceSources.when_finding_missing_sources; + +public class and_one_of_them_is_compiled : given.an_intermediate_output_folder +{ + IReadOnlyList _result; + + void Because() => _result = GeneratedResourceSources.MissingFrom(_assemblyPath, [_adminMessages, null]); + + [Fact] void should_find_only_the_other_one() => _result.ShouldContainOnly([_commonMessages]); +} diff --git a/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_the_intermediate_assembly_is_not_known.cs b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_the_intermediate_assembly_is_not_known.cs new file mode 100644 index 0000000..1075583 --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_the_intermediate_assembly_is_not_known.cs @@ -0,0 +1,13 @@ +// 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_GeneratedResourceSources.when_finding_missing_sources; + +public class and_the_intermediate_assembly_is_not_known : Specification +{ + IReadOnlyList _result; + + void Because() => _result = GeneratedResourceSources.MissingFrom(null, []); + + [Fact] void should_find_nothing_missing() => _result.ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_the_project_has_never_been_built.cs b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_the_project_has_never_been_built.cs new file mode 100644 index 0000000..cb2bc84 --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_the_project_has_never_been_built.cs @@ -0,0 +1,15 @@ +// 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_GeneratedResourceSources.when_finding_missing_sources; + +public class and_the_project_has_never_been_built : given.an_intermediate_output_folder +{ + IReadOnlyList _result; + + void Because() => _result = GeneratedResourceSources.MissingFrom( + Path.Combine(_folder, "obj", "Release", "net10.0", "MyApp.dll"), + []); + + [Fact] void should_find_nothing_missing() => _result.ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_they_are_all_compiled.cs b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_they_are_all_compiled.cs new file mode 100644 index 0000000..9cddaec --- /dev/null +++ b/Source/Cli.Specs/for_GeneratedResourceSources/when_finding_missing_sources/and_they_are_all_compiled.cs @@ -0,0 +1,13 @@ +// 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_GeneratedResourceSources.when_finding_missing_sources; + +public class and_they_are_all_compiled : given.an_intermediate_output_folder +{ + IReadOnlyList _result; + + void Because() => _result = GeneratedResourceSources.MissingFrom(_assemblyPath, [_adminMessages, _commonMessages]); + + [Fact] void should_find_nothing_missing() => _result.ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_PlayFileTargetResolver/given/a_temporary_folder.cs b/Source/Cli.Specs/for_PlayFileTargetResolver/given/a_temporary_folder.cs new file mode 100644 index 0000000..9f5efe9 --- /dev/null +++ b/Source/Cli.Specs/for_PlayFileTargetResolver/given/a_temporary_folder.cs @@ -0,0 +1,19 @@ +// 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_PlayFileTargetResolver.given; + +public class a_temporary_folder : Specification +{ + protected string _folder; + + void Establish() => _folder = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())).FullName; + + void Destroy() + { + if (Directory.Exists(_folder)) + { + Directory.Delete(_folder, true); + } + } +} diff --git a/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_no_path_is_given.cs b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_no_path_is_given.cs new file mode 100644 index 0000000..92f3729 --- /dev/null +++ b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_no_path_is_given.cs @@ -0,0 +1,13 @@ +// 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_PlayFileTargetResolver.when_resolving; + +public class and_no_path_is_given : given.a_temporary_folder +{ + ScreenplayTarget _result; + + void Because() => _result = PlayFileTargetResolver.Resolve(null, _folder); + + [Fact] void should_resolve_the_current_directory() => _result.Path.ShouldEqual(_folder); +} diff --git a/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_file_is_not_a_play_file.cs b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_file_is_not_a_play_file.cs new file mode 100644 index 0000000..011f2d3 --- /dev/null +++ b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_file_is_not_a_play_file.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_PlayFileTargetResolver.when_resolving; + +public class and_the_file_is_not_a_play_file : given.a_temporary_folder +{ + ScreenplayTarget _result; + + void Establish() => File.WriteAllText(Path.Combine(_folder, "readme.md"), "not a Screenplay"); + + void Because() => _result = PlayFileTargetResolver.Resolve("readme.md", _folder); + + [Fact] void should_not_resolve() => _result.IsResolved.ShouldBeFalse(); + [Fact] void should_report_that_it_is_not_a_play_file() => _result.Error.ShouldContain("is not a Screenplay (.play) file"); + [Fact] void should_suggest_what_to_point_at() => _result.Suggestion.ShouldNotBeNull(); +} diff --git a/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_does_not_exist.cs b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_does_not_exist.cs new file mode 100644 index 0000000..f1e8f03 --- /dev/null +++ b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_does_not_exist.cs @@ -0,0 +1,15 @@ +// 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_PlayFileTargetResolver.when_resolving; + +public class and_the_path_does_not_exist : given.a_temporary_folder +{ + ScreenplayTarget _result; + + void Because() => _result = PlayFileTargetResolver.Resolve("Missing/MyApp.play", _folder); + + [Fact] void should_not_resolve() => _result.IsResolved.ShouldBeFalse(); + [Fact] void should_report_that_it_does_not_exist() => _result.Error.ShouldContain("does not exist"); + [Fact] void should_suggest_what_to_point_at() => _result.Suggestion.ShouldNotBeNull(); +} diff --git a/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_is_a_folder.cs b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_is_a_folder.cs new file mode 100644 index 0000000..a2c7095 --- /dev/null +++ b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_is_a_folder.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_PlayFileTargetResolver.when_resolving; + +public class and_the_path_is_a_folder : given.a_temporary_folder +{ + string _nested; + ScreenplayTarget _result; + + void Establish() => _nested = Directory.CreateDirectory(Path.Combine(_folder, "plays")).FullName; + + void Because() => _result = PlayFileTargetResolver.Resolve("plays", _folder); + + [Fact] void should_resolve() => _result.IsResolved.ShouldBeTrue(); + [Fact] void should_resolve_the_folder_itself() => _result.Path.ShouldEqual(_nested); +} diff --git a/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_is_a_play_file.cs b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_is_a_play_file.cs new file mode 100644 index 0000000..0004b5e --- /dev/null +++ b/Source/Cli.Specs/for_PlayFileTargetResolver/when_resolving/and_the_path_is_a_play_file.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_PlayFileTargetResolver.when_resolving; + +public class and_the_path_is_a_play_file : given.a_temporary_folder +{ + string _document; + ScreenplayTarget _result; + + void Establish() + { + _document = Path.Combine(_folder, "MyApp.play"); + File.WriteAllText(_document, "domain Library\n"); + } + + void Because() => _result = PlayFileTargetResolver.Resolve("MyApp.play", _folder); + + [Fact] void should_resolve() => _result.IsResolved.ShouldBeTrue(); + [Fact] void should_resolve_the_document_relative_to_the_current_directory() => _result.Path.ShouldEqual(_document); + [Fact] void should_not_report_an_error() => _result.Error.ShouldBeNull(); +} diff --git a/Source/Cli.Specs/for_ScreenplayDiagnostics/when_grouping_by_severity/and_severities_are_mixed.cs b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_grouping_by_severity/and_severities_are_mixed.cs new file mode 100644 index 0000000..eaa072d --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_grouping_by_severity/and_severities_are_mixed.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_ScreenplayDiagnostics.when_grouping_by_severity; + +public class and_severities_are_mixed : Specification +{ + IReadOnlyList> _result; + + void Because() => _result = ScreenplayDiagnostics.GroupBySeverity( + [ + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, "SP0100", "a warning", null), + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, "SP0200", "an error", null), + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, "SP0101", "another warning", null) + ]); + + [Fact] void should_produce_one_group_per_severity() => _result.Count.ShouldEqual(2); + [Fact] void should_put_the_errors_first() => _result[0].Key.ShouldEqual(ScreenplayDiagnosticSeverity.Error); + [Fact] void should_put_the_warnings_after_them() => _result[1].Key.ShouldEqual(ScreenplayDiagnosticSeverity.Warning); + [Fact] void should_keep_both_warnings_together() => _result[1].Count().ShouldEqual(2); +} diff --git a/Source/Cli.Specs/for_ScreenplayDiagnostics/when_heading_a_group/and_the_group_holds_information.cs b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_heading_a_group/and_the_group_holds_information.cs new file mode 100644 index 0000000..3a99d42 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_heading_a_group/and_the_group_holds_information.cs @@ -0,0 +1,13 @@ +// 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_ScreenplayDiagnostics.when_heading_a_group; + +public class and_the_group_holds_information : Specification +{ + string _result; + + void Because() => _result = ScreenplayDiagnosticsWriter.GroupHeadingFor(ScreenplayDiagnosticSeverity.Information); + + [Fact] void should_read_as_english_rather_than_a_pluralized_label() => _result.ShouldEqual("information"); +} diff --git a/Source/Cli.Specs/for_ScreenplayDiagnostics/when_ordering/and_severities_are_mixed.cs b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_ordering/and_severities_are_mixed.cs new file mode 100644 index 0000000..80cb19f --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_ordering/and_severities_are_mixed.cs @@ -0,0 +1,23 @@ +// 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_ScreenplayDiagnostics.when_ordering; + +public class and_severities_are_mixed : Specification +{ + IReadOnlyList _result; + + void Because() => _result = ScreenplayDiagnostics.Order( + [ + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Information, "SP0300", "third", null), + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, "SP0200", "second", "Library.Lending"), + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, "SP0100", "first", null), + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, "SP0100", "zeroth", "Library.Authors") + ]); + + [Fact] void should_put_the_errors_first() => _result[0].Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Error); + [Fact] void should_order_errors_by_code() => _result[0].Code.ShouldEqual("SP0100"); + [Fact] void should_keep_the_second_error_next() => _result[1].Code.ShouldEqual("SP0200"); + [Fact] void should_put_the_warning_after_the_errors() => _result[2].Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Warning); + [Fact] void should_put_the_information_last() => _result[3].Severity.ShouldEqual(ScreenplayDiagnosticSeverity.Information); +} diff --git a/Source/Cli.Specs/for_ScreenplayDiagnostics/when_resolving_the_exit_code/and_one_of_them_is_an_error.cs b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_resolving_the_exit_code/and_one_of_them_is_an_error.cs new file mode 100644 index 0000000..1a30646 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_resolving_the_exit_code/and_one_of_them_is_an_error.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_ScreenplayDiagnostics.when_resolving_the_exit_code; + +public class and_one_of_them_is_an_error : Specification +{ + int _result; + + void Because() => _result = ScreenplayDiagnostics.ExitCodeFor( + [ + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, "SP0100", "a warning", null), + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, "SP0200", "an error", null) + ]); + + [Fact] void should_be_a_validation_error() => _result.ShouldEqual(ExitCodes.ValidationError); + [Fact] void should_not_be_success() => _result.ShouldNotEqual(ExitCodes.Success); +} diff --git a/Source/Cli.Specs/for_ScreenplayDiagnostics/when_resolving_the_exit_code/and_there_are_only_warnings.cs b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_resolving_the_exit_code/and_there_are_only_warnings.cs new file mode 100644 index 0000000..8017bb9 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDiagnostics/when_resolving_the_exit_code/and_there_are_only_warnings.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_ScreenplayDiagnostics.when_resolving_the_exit_code; + +public class and_there_are_only_warnings : Specification +{ + int _result; + + void Because() => _result = ScreenplayDiagnostics.ExitCodeFor( + [ + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, "SP0100", "a warning", null), + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Information, "SP0300", "a note", null) + ]); + + [Fact] void should_be_success() => _result.ShouldEqual(ExitCodes.Success); +} diff --git a/Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_a_code.cs b/Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_a_code.cs new file mode 100644 index 0000000..4101ba9 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_a_code.cs @@ -0,0 +1,14 @@ +// 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_ScreenplayDiagnosticsWriter.when_formatting_a_line; + +public class and_the_diagnostic_has_a_code : Specification +{ + string _result; + + void Because() => _result = ScreenplayDiagnosticsWriter.LineFor( + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, "SP0200", "an error", "Library.Lending")); + + [Fact] void should_write_the_code_before_the_location() => _result.ShouldEqual(" error SP0200: [Library.Lending] an error"); +} diff --git a/Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_no_code.cs b/Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_no_code.cs new file mode 100644 index 0000000..8bf5b0b --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_no_code.cs @@ -0,0 +1,14 @@ +// 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_ScreenplayDiagnosticsWriter.when_formatting_a_line; + +public class and_the_diagnostic_has_no_code : Specification +{ + string _result; + + void Because() => _result = ScreenplayDiagnosticsWriter.LineFor( + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, string.Empty, "a warning", "MyApp.play(3,1)")); + + [Fact] void should_leave_the_code_out() => _result.ShouldEqual(" warning: [MyApp.play(3,1)] a warning"); +} diff --git a/Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_no_location.cs b/Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_no_location.cs new file mode 100644 index 0000000..2972de7 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDiagnosticsWriter/when_formatting_a_line/and_the_diagnostic_has_no_location.cs @@ -0,0 +1,14 @@ +// 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_ScreenplayDiagnosticsWriter.when_formatting_a_line; + +public class and_the_diagnostic_has_no_location : Specification +{ + string _result; + + void Because() => _result = ScreenplayDiagnosticsWriter.LineFor( + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Information, "SP0001", "something to know", null)); + + [Fact] void should_leave_the_location_out() => _result.ShouldEqual(" info SP0001: something to know"); +} diff --git a/Source/Cli.Specs/for_ScreenplayDocument/when_writing/and_the_source_ends_with_a_single_newline.cs b/Source/Cli.Specs/for_ScreenplayDocument/when_writing/and_the_source_ends_with_a_single_newline.cs new file mode 100644 index 0000000..91ade28 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDocument/when_writing/and_the_source_ends_with_a_single_newline.cs @@ -0,0 +1,24 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; + +namespace Cratis.Cli.for_ScreenplayDocument.when_writing; + +public class and_the_source_ends_with_a_single_newline : Specification +{ + const string Source = "domain Library\n\nmodule Library\n"; + byte[] _written; + + async Task Because() + { + await using var stream = new MemoryStream(); + await ScreenplayDocument.Write(stream, Source, CancellationToken.None); + _written = stream.ToArray(); + } + + [Fact] void should_write_the_source_byte_for_byte() => _written.ShouldEqual(Encoding.UTF8.GetBytes(Source)); + [Fact] void should_not_emit_a_byte_order_mark() => _written[0].ShouldEqual((byte)'d'); + [Fact] void should_end_with_exactly_one_newline() => _written[^1].ShouldEqual((byte)'\n'); + [Fact] void should_not_end_with_two_newlines() => _written[^2].ShouldNotEqual((byte)'\n'); +} diff --git a/Source/Cli.Specs/for_ScreenplayDocument/when_writing/and_the_source_holds_non_ascii_characters.cs b/Source/Cli.Specs/for_ScreenplayDocument/when_writing/and_the_source_holds_non_ascii_characters.cs new file mode 100644 index 0000000..6450699 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayDocument/when_writing/and_the_source_holds_non_ascii_characters.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. + +using System.Text; + +namespace Cratis.Cli.for_ScreenplayDocument.when_writing; + +public class and_the_source_holds_non_ascii_characters : Specification +{ + const string Source = "domain Bibliothèque\n\nmodule Utlån\n"; + byte[] _written; + + async Task Because() + { + await using var stream = new MemoryStream(); + await ScreenplayDocument.Write(stream, Source, CancellationToken.None); + _written = stream.ToArray(); + } + + [Fact] void should_write_them_as_utf8() => _written.ShouldEqual(Encoding.UTF8.GetBytes(Source)); +} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_one_project_is_an_executable.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_one_project_is_an_executable.cs new file mode 100644 index 0000000..4786163 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_one_project_is_an_executable.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_ScreenplayProjectSelection.when_narrowing; + +public class and_one_project_is_an_executable : Specification +{ + string? _result; + + void Because() => _result = ScreenplayProjectSelection.Select( + [ + new ScreenplayProjectCandidate("MyApp.Domain", false), + new ScreenplayProjectCandidate("MyApp.Api", true), + new ScreenplayProjectCandidate("MyApp.Read", false) + ]); + + [Fact] void should_select_the_executable() => _result.ShouldEqual("MyApp.Api"); +} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_spec_projects_are_present.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_spec_projects_are_present.cs new file mode 100644 index 0000000..bd25617 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_spec_projects_are_present.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_ScreenplayProjectSelection.when_narrowing; + +public class and_spec_projects_are_present : Specification +{ + string? _result; + + void Because() => _result = ScreenplayProjectSelection.Select( + [ + new ScreenplayProjectCandidate("MyApp", false), + new ScreenplayProjectCandidate("MyApp.Specs", false), + new ScreenplayProjectCandidate("MyApp.Tests", false) + ]); + + [Fact] void should_select_the_only_project_that_is_not_specs() => _result.ShouldEqual("MyApp"); +} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_there_are_only_spec_projects.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_there_are_only_spec_projects.cs new file mode 100644 index 0000000..f33d59e --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_there_are_only_spec_projects.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_ScreenplayProjectSelection.when_narrowing; + +public class and_there_are_only_spec_projects : Specification +{ + IReadOnlyList _result; + + void Because() => _result = ScreenplayProjectSelection.Narrow( + [ + new ScreenplayProjectCandidate("MyApp.Specs", false), + new ScreenplayProjectCandidate("MyApp.IntegrationTests", true) + ]); + + [Fact] void should_leave_nothing_to_generate_from() => _result.ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_two_libraries_remain.cs b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_two_libraries_remain.cs new file mode 100644 index 0000000..e8ebd20 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayProjectSelection/when_narrowing/and_two_libraries_remain.cs @@ -0,0 +1,26 @@ +// 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_ScreenplayProjectSelection.when_narrowing; + +public class and_two_libraries_remain : Specification +{ + string? _result; + IReadOnlyList _narrowed; + + void Because() + { + ScreenplayProjectCandidate[] candidates = + [ + new("MyApp.Ordering", false), + new("MyApp.Billing", false) + ]; + + _narrowed = ScreenplayProjectSelection.Narrow(candidates); + _result = ScreenplayProjectSelection.Select(candidates); + } + + [Fact] void should_not_select_any_of_them() => _result.ShouldBeNull(); + [Fact] void should_keep_both_as_candidates() => _narrowed.Count.ShouldEqual(2); + [Fact] void should_order_the_candidates_by_name() => _narrowed[0].Name.ShouldEqual("MyApp.Billing"); +} diff --git a/Source/Cli.Specs/for_ScreenplayTargetResolver/given/a_temporary_folder.cs b/Source/Cli.Specs/for_ScreenplayTargetResolver/given/a_temporary_folder.cs new file mode 100644 index 0000000..7c41f4c --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayTargetResolver/given/a_temporary_folder.cs @@ -0,0 +1,19 @@ +// 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_ScreenplayTargetResolver.given; + +public class a_temporary_folder : Specification +{ + protected string _folder; + + void Establish() => _folder = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())).FullName; + + void Destroy() + { + if (Directory.Exists(_folder)) + { + Directory.Delete(_folder, true); + } + } +} diff --git a/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_a_parent_folder_holds_the_project.cs b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_a_parent_folder_holds_the_project.cs new file mode 100644 index 0000000..e8ec70c --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_a_parent_folder_holds_the_project.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_ScreenplayTargetResolver.when_resolving; + +public class and_a_parent_folder_holds_the_project : given.a_temporary_folder +{ + string _project; + string _nested; + ScreenplayTarget _result; + + void Establish() + { + _project = Path.Combine(_folder, "MyApp.csproj"); + File.WriteAllText(_project, ""); + _nested = Directory.CreateDirectory(Path.Combine(_folder, "Features", "Authors")).FullName; + } + + void Because() => _result = ScreenplayTargetResolver.Resolve(null, _nested); + + [Fact] void should_find_the_project_by_searching_upwards() => _result.Path.ShouldEqual(_project); +} diff --git a/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_nothing_can_be_discovered.cs b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_nothing_can_be_discovered.cs new file mode 100644 index 0000000..a56a27d --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_nothing_can_be_discovered.cs @@ -0,0 +1,15 @@ +// 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_ScreenplayTargetResolver.when_resolving; + +public class and_nothing_can_be_discovered : given.a_temporary_folder +{ + ScreenplayTarget _result; + + void Because() => _result = ScreenplayTargetResolver.Resolve(_folder, _folder); + + [Fact] void should_not_resolve() => _result.IsResolved.ShouldBeFalse(); + [Fact] void should_report_that_nothing_was_found() => _result.Error.ShouldContain("No solution or project file found"); + [Fact] void should_suggest_passing_a_path() => _result.Suggestion.ShouldContain("pass the path to one"); +} diff --git a/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_file_is_not_a_solution_or_project.cs b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_file_is_not_a_solution_or_project.cs new file mode 100644 index 0000000..08f6254 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_file_is_not_a_solution_or_project.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_ScreenplayTargetResolver.when_resolving; + +public class and_the_file_is_not_a_solution_or_project : given.a_temporary_folder +{ + ScreenplayTarget _result; + + void Establish() => File.WriteAllText(Path.Combine(_folder, "readme.md"), "not a project"); + + void Because() => _result = ScreenplayTargetResolver.Resolve("readme.md", _folder); + + [Fact] void should_not_resolve() => _result.IsResolved.ShouldBeFalse(); + [Fact] void should_report_that_it_is_not_a_solution_or_project() => _result.Error.ShouldContain("is not a solution or project file"); +} diff --git a/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_folder_holds_a_solution.cs b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_folder_holds_a_solution.cs new file mode 100644 index 0000000..07492e7 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_folder_holds_a_solution.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_ScreenplayTargetResolver.when_resolving; + +public class and_the_folder_holds_a_solution : given.a_temporary_folder +{ + string _solution; + ScreenplayTarget _result; + + void Establish() + { + _solution = Path.Combine(_folder, "MyApp.slnx"); + File.WriteAllText(_solution, ""); + File.WriteAllText(Path.Combine(_folder, "MyApp.csproj"), ""); + } + + void Because() => _result = ScreenplayTargetResolver.Resolve(null, _folder); + + [Fact] void should_prefer_the_solution_over_the_project() => _result.Path.ShouldEqual(_solution); +} diff --git a/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_path_does_not_exist.cs b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_path_does_not_exist.cs new file mode 100644 index 0000000..24686d0 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_path_does_not_exist.cs @@ -0,0 +1,15 @@ +// 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_ScreenplayTargetResolver.when_resolving; + +public class and_the_path_does_not_exist : given.a_temporary_folder +{ + ScreenplayTarget _result; + + void Because() => _result = ScreenplayTargetResolver.Resolve("Missing/MyApp.csproj", _folder); + + [Fact] void should_not_resolve() => _result.IsResolved.ShouldBeFalse(); + [Fact] void should_report_that_it_does_not_exist() => _result.Error.ShouldContain("does not exist"); + [Fact] void should_suggest_what_to_point_at() => _result.Suggestion.ShouldNotBeNull(); +} diff --git a/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_path_is_a_project_file.cs b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_path_is_a_project_file.cs new file mode 100644 index 0000000..7fd1b52 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayTargetResolver/when_resolving/and_the_path_is_a_project_file.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_ScreenplayTargetResolver.when_resolving; + +public class and_the_path_is_a_project_file : given.a_temporary_folder +{ + string _project; + ScreenplayTarget _result; + + void Establish() + { + _project = Path.Combine(_folder, "MyApp.csproj"); + File.WriteAllText(_project, ""); + } + + void Because() => _result = ScreenplayTargetResolver.Resolve("MyApp.csproj", _folder); + + [Fact] void should_resolve() => _result.IsResolved.ShouldBeTrue(); + [Fact] void should_resolve_the_project_relative_to_the_current_directory() => _result.Path.ShouldEqual(_project); + [Fact] void should_not_report_an_error() => _result.Error.ShouldBeNull(); +} diff --git a/Source/Cli.Specs/for_ScreenplayValidation/given/a_folder_with_documents.cs b/Source/Cli.Specs/for_ScreenplayValidation/given/a_folder_with_documents.cs new file mode 100644 index 0000000..8ec1f08 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayValidation/given/a_folder_with_documents.cs @@ -0,0 +1,44 @@ +// 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_ScreenplayValidation.given; + +/// +/// Base context that puts documents in a temporary folder and compiles them with the real Screenplay compiler. +/// +public class a_folder_with_documents : Specification +{ + protected const string ValidSource = "domain Library\n\nmodule Library\n"; + protected const string InvalidSource = "domain Library\n\nmodule Library\n feature Lending\n slice Reserving\n"; + + protected string _folder; + protected ScreenplayValidation _validation; + + void Establish() + { + _folder = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())).FullName; + _validation = new ScreenplayValidation(); + } + + /// + /// Writes a document into the folder. + /// + /// The file name to write, relative to the folder. + /// The source to write. + /// The full path of the written document. + protected string WriteDocument(string name, string source) + { + var path = Path.Combine(_folder, name); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, source); + return path; + } + + void Destroy() + { + if (Directory.Exists(_folder)) + { + Directory.Delete(_folder, true); + } + } +} diff --git a/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_document_has_an_error.cs b/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_document_has_an_error.cs new file mode 100644 index 0000000..8c137ea --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_document_has_an_error.cs @@ -0,0 +1,19 @@ +// 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_ScreenplayValidation.when_validating; + +public class and_the_document_has_an_error : given.a_folder_with_documents +{ + string _document; + ValidatedScreenplay _result; + + void Establish() => _document = WriteDocument("MyApp.play", InvalidSource); + + void Because() => _result = _validation.Validate(_document); + + [Fact] void should_compile_the_document() => _result.FileCount.ShouldEqual(1); + [Fact] void should_report_an_error() => ScreenplayDiagnostics.HasErrors(_result.Diagnostics).ShouldBeTrue(); + [Fact] void should_point_at_the_file_and_position() => _result.Diagnostics[0].Location.ShouldEqual("MyApp.play(5,5)"); + [Fact] void should_not_invent_a_code() => _result.Diagnostics[0].Code.ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_document_is_valid.cs b/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_document_is_valid.cs new file mode 100644 index 0000000..d445443 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_document_is_valid.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_ScreenplayValidation.when_validating; + +public class and_the_document_is_valid : given.a_folder_with_documents +{ + string _document; + ValidatedScreenplay _result; + + void Establish() => _document = WriteDocument("MyApp.play", ValidSource); + + void Because() => _result = _validation.Validate(_document); + + [Fact] void should_compile_the_document() => _result.FileCount.ShouldEqual(1); + [Fact] void should_not_report_anything() => _result.Diagnostics.ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_folder_holds_no_documents.cs b/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_folder_holds_no_documents.cs new file mode 100644 index 0000000..33c4b5b --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_folder_holds_no_documents.cs @@ -0,0 +1,14 @@ +// 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_ScreenplayValidation.when_validating; + +public class and_the_folder_holds_no_documents : given.a_folder_with_documents +{ + ValidatedScreenplay _result; + + void Because() => _result = _validation.Validate(_folder); + + [Fact] void should_compile_nothing() => _result.FileCount.ShouldEqual(0); + [Fact] void should_not_report_anything() => _result.Diagnostics.ShouldBeEmpty(); +} diff --git a/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_folder_holds_several_documents.cs b/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_folder_holds_several_documents.cs new file mode 100644 index 0000000..2aebe00 --- /dev/null +++ b/Source/Cli.Specs/for_ScreenplayValidation/when_validating/and_the_folder_holds_several_documents.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_ScreenplayValidation.when_validating; + +public class and_the_folder_holds_several_documents : given.a_folder_with_documents +{ + ValidatedScreenplay _result; + + void Establish() + { + WriteDocument("MyApp.play", ValidSource); + WriteDocument(Path.Combine("nested", "Broken.play"), InvalidSource); + } + + void Because() => _result = _validation.Validate(_folder); + + [Fact] void should_compile_every_document_beneath_it() => _result.FileCount.ShouldEqual(2); + [Fact] void should_report_the_error_in_the_nested_document() => ScreenplayDiagnostics.HasErrors(_result.Diagnostics).ShouldBeTrue(); + [Fact] void should_point_at_the_document_relative_to_the_folder() => _result.Diagnostics[0].Location.ShouldEqual("nested/Broken.play(5,5)"); +} diff --git a/Source/Cli.Specs/for_ValidateScreenplayCommand/given/a_validate_screenplay_command.cs b/Source/Cli.Specs/for_ValidateScreenplayCommand/given/a_validate_screenplay_command.cs new file mode 100644 index 0000000..4f90583 --- /dev/null +++ b/Source/Cli.Specs/for_ValidateScreenplayCommand/given/a_validate_screenplay_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_ValidateScreenplayCommand.given; + +/// +/// Base context that puts a document in a temporary folder and substitutes the validation. +/// +public class a_validate_screenplay_command : Specification +{ + protected string _folder; + protected string _document; + protected string _previousDirectory; + protected IScreenplayValidation _validation; + protected ValidateScreenplayCommand _command; + protected ValidateScreenplaySettings _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"); + + _validation = Substitute.For(); + _validation.Validate(Arg.Any()).Returns(new ValidatedScreenplay(1, [])); + + _command = new ValidateScreenplayCommand(_validation); + _settings = new ValidateScreenplaySettings { Output = OutputFormats.JsonCompact }; + } + + /// + /// Executes the command with the established settings. + /// + /// The exit code. + protected Task Execute() => + ((ICommand)_command).ExecuteAsync( + new CommandContext([], Substitute.For(), "validate", null), + _settings, + CancellationToken.None); + + void Destroy() + { + Directory.SetCurrentDirectory(_previousDirectory); + + if (Directory.Exists(_folder)) + { + Directory.Delete(_folder, true); + } + } +} diff --git a/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_no_documents_are_found.cs b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_no_documents_are_found.cs new file mode 100644 index 0000000..e4cdf7b --- /dev/null +++ b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_no_documents_are_found.cs @@ -0,0 +1,19 @@ +// 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_ValidateScreenplayCommand.when_validating; + +[Collection(CliSpecsCollection.Name)] +public class and_no_documents_are_found : given.a_validate_screenplay_command +{ + int _result; + + void Establish() => + _validation + .Validate(Arg.Any()) + .Returns(new ValidatedScreenplay(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_ValidateScreenplayCommand/when_validating/and_no_path_is_given.cs b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_no_path_is_given.cs new file mode 100644 index 0000000..cddefad --- /dev/null +++ b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_no_path_is_given.cs @@ -0,0 +1,15 @@ +// 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_ValidateScreenplayCommand.when_validating; + +[Collection(CliSpecsCollection.Name)] +public class and_no_path_is_given : given.a_validate_screenplay_command +{ + int _result; + + async Task Because() => _result = await Execute(); + + [Fact] void should_succeed() => _result.ShouldEqual(ExitCodes.Success); + [Fact] void should_validate_the_current_directory() => _validation.Received(1).Validate(_folder); +} diff --git a/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_the_documents_are_valid.cs b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_the_documents_are_valid.cs new file mode 100644 index 0000000..e8dc3fd --- /dev/null +++ b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_the_documents_are_valid.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_ValidateScreenplayCommand.when_validating; + +[Collection(CliSpecsCollection.Name)] +public class and_the_documents_are_valid : given.a_validate_screenplay_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_validate_the_resolved_document() => _validation.Received(1).Validate(_document); +} diff --git a/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_the_path_cannot_be_resolved.cs b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_the_path_cannot_be_resolved.cs new file mode 100644 index 0000000..8bbfd41 --- /dev/null +++ b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/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_ValidateScreenplayCommand.when_validating; + +[Collection(CliSpecsCollection.Name)] +public class and_the_path_cannot_be_resolved : given.a_validate_screenplay_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_validate_anything() => _validation.DidNotReceive().Validate(Arg.Any()); +} diff --git a/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_validation_reports_an_error.cs b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_validation_reports_an_error.cs new file mode 100644 index 0000000..d32d6de --- /dev/null +++ b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_validation_reports_an_error.cs @@ -0,0 +1,24 @@ +// 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_ValidateScreenplayCommand.when_validating; + +[Collection(CliSpecsCollection.Name)] +public class and_validation_reports_an_error : given.a_validate_screenplay_command +{ + int _result; + + void Establish() => + _validation + .Validate(Arg.Any()) + .Returns(new ValidatedScreenplay( + 1, + [ + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, string.Empty, "a warning", "MyApp.play(3,1)"), + new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, string.Empty, "an error", "MyApp.play(5,5)") + ])); + + async Task Because() => _result = await Execute(); + + [Fact] void should_fail_with_a_validation_error() => _result.ShouldEqual(ExitCodes.ValidationError); +} diff --git a/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_validation_reports_only_warnings.cs b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_validation_reports_only_warnings.cs new file mode 100644 index 0000000..5ae8ac3 --- /dev/null +++ b/Source/Cli.Specs/for_ValidateScreenplayCommand/when_validating/and_validation_reports_only_warnings.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_ValidateScreenplayCommand.when_validating; + +[Collection(CliSpecsCollection.Name)] +public class and_validation_reports_only_warnings : given.a_validate_screenplay_command +{ + int _result; + + void Establish() => + _validation + .Validate(Arg.Any()) + .Returns(new ValidatedScreenplay( + 1, + [new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Warning, string.Empty, "a warning", "MyApp.play(3,1)")])); + + async Task Because() => _result = await Execute(); + + [Fact] void should_succeed() => _result.ShouldEqual(ExitCodes.Success); +} diff --git a/Source/Cli/Cli.csproj b/Source/Cli/Cli.csproj index 27d7c45..6447a44 100644 --- a/Source/Cli/Cli.csproj +++ b/Source/Cli/Cli.csproj @@ -19,6 +19,7 @@ + @@ -35,6 +36,13 @@ + + + + + + + diff --git a/Source/Cli/Commands/Screenplay/ArcScreenplayGeneration.cs b/Source/Cli/Commands/Screenplay/ArcScreenplayGeneration.cs new file mode 100644 index 0000000..d360d72 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ArcScreenplayGeneration.cs @@ -0,0 +1,47 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay; + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Generates a Screenplay document by loading the target into a Roslyn compilation and handing it to the +/// Cratis.Arc.Screenplay generator. +/// +/// +/// This is the only place in the CLI that knows the generator exists. Everything else is expressed against +/// . +/// +public sealed class ArcScreenplayGeneration : IScreenplayGeneration +{ + /// + public async Task Generate(string targetPath, ScreenplayGenerationOptions options, CancellationToken cancellationToken) + { + var loaded = await ScreenplayCompilationLoader.Load(targetPath, cancellationToken); + if (loaded.Compilation is null) + { + return new GeneratedScreenplay(string.Empty, loaded.Diagnostics); + } + + var result = new ScreenplayGenerator().Generate( + loaded.Compilation, + new ScreenplayOptions + { + Domain = options.Domain, + Module = options.Module, + SegmentsToSkip = options.SegmentsToSkip + }); + + return new GeneratedScreenplay( + result.Source, + [.. loaded.Diagnostics, .. result.Diagnostics.Select(Map)]); + } + + static ScreenplayDiagnostic Map(Cratis.Arc.Screenplay.ScreenplayDiagnostic diagnostic) => + new( + (ScreenplayDiagnosticSeverity)(int)diagnostic.Severity, + diagnostic.Code, + diagnostic.Message, + diagnostic.Location); +} diff --git a/Source/Cli/Commands/Screenplay/DesignTimeResourceGeneration.cs b/Source/Cli/Commands/Screenplay/DesignTimeResourceGeneration.cs new file mode 100644 index 0000000..4c502ca --- /dev/null +++ b/Source/Cli/Commands/Screenplay/DesignTimeResourceGeneration.cs @@ -0,0 +1,117 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security; + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Makes the MSBuild design-time build a workspace performs generate the strongly typed resource classes that +/// .resx files ask for, so that the loaded compilation holds the same sources a real build compiles. +/// +/// +/// A design-time build only runs the Compile target. PrepareResources — the target chain that turns +/// <Generator>MSBuild:Compile</Generator> resource entries into .Designer.cs sources and +/// adds them to @(Compile) — is a sibling of Compile within CoreBuild rather than one of its +/// dependencies, so it never runs. Every use of a generated resource class then becomes a compile error, and a +/// perfectly ordinary application looks like it does not compile at all. +/// +/// Injecting a targets file through the CustomAfterMicrosoftCommonTargets hook puts PrepareResources +/// back in front of CoreCompile, which is exactly where a real build runs it. Nothing is hard coded — MSBuild +/// generates the sources into the project's own intermediate output folder and adds them to the compilation itself, +/// whether or not the project has ever been built. +/// +/// +public sealed class DesignTimeResourceGeneration : IDisposable +{ + /// + /// The MSBuild property naming a targets file that every project imports after the common targets. + /// + public const string HookProperty = "CustomAfterMicrosoftCommonTargets"; + + readonly string? _folder; + + DesignTimeResourceGeneration(string? folder, Dictionary globalProperties) + { + _folder = folder; + GlobalProperties = globalProperties; + } + + /// + /// Gets the global properties to open the MSBuild workspace with. + /// + public IDictionary GlobalProperties { get; } + + /// + /// Writes the targets file to a temporary folder and describes how to hand it to a workspace. + /// + /// The owning the written file. + /// + /// Falls back to opening the workspace unchanged when the file cannot be written — a missing resource class is a + /// far better outcome than not being able to read the application at all. + /// + public static DesignTimeResourceGeneration Create() + { + try + { + var folder = Directory.CreateTempSubdirectory("cratis-screenplay-").FullName; + var file = Path.Combine(folder, "Cratis.Screenplay.DesignTimeResources.targets"); + File.WriteAllText(file, Targets(Environment.GetEnvironmentVariable(HookProperty))); + return new(folder, new(StringComparer.Ordinal) { [HookProperty] = file }); + } + catch (IOException) + { + return new(null, new(StringComparer.Ordinal)); + } + catch (UnauthorizedAccessException) + { + return new(null, new(StringComparer.Ordinal)); + } + } + + /// + /// Builds the content of the targets file every loaded project imports. + /// + /// A targets file the hook already pointed at, which the injected one must keep importing; ignored when it does not exist. + /// The MSBuild project content. + public static string Targets(string? chained) + { + List lines = [""]; + + if (!string.IsNullOrWhiteSpace(chained) && File.Exists(chained)) + { + lines.Add($" "); + } + + lines.AddRange( + " ", + " ", + "", + string.Empty); + + return string.Join('\n', lines); + } + + /// + public void Dispose() + { + if (_folder is null || !Directory.Exists(_folder)) + { + return; + } + + try + { + Directory.Delete(_folder, true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} diff --git a/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs b/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs new file mode 100644 index 0000000..32cb055 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/GenerateScreenplayCommand.cs @@ -0,0 +1,172 @@ +// 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.Commands.Screenplay; + +/// +/// Generates a Cratis Screenplay (.play) file from the source code of a Cratis Arc application — reads the +/// solution or project with Roslyn, hands the compilation to the Screenplay generator, and writes the result. +/// +[LlmDescription("Generates a Cratis Screenplay (.play) file from Cratis Arc SOURCE CODE. Reads a solution or project with Roslyn — it never connects to a running application, so nothing needs to be started first. Writes the .play source to standard output unless --file is given. Diagnostics for anything that could not be expressed go to standard error, grouped by severity; the command exits with a validation error when any of them is an error.")] +[CliCommand("generate", "Generate a Screenplay from Arc source code", Branch = typeof(ScreenplayBranch))] +[CliExample("screenplay", "generate")] +[CliExample("screenplay", "generate", "./MyApp.slnx", "--file", "MyApp.play")] +[CliExample("screenplay", "generate", "./Source/MyApp/MyApp.csproj")] +[LlmOption("[PATH]", "string", "Solution (.slnx, .sln), project (.csproj), or folder to read. Defaults to the current directory, searching upwards for a solution or project.")] +[LlmOption("--file", "string", "File to write the generated Screenplay to. Writes to standard output when not given.")] +[LlmOption("--domain", "string", "Name of the domain the generated document belongs to.")] +[LlmOption("--module", "string", "Name of the module every discovered feature is placed within.")] +[LlmOption("--skip-segments", "int", "Number of leading namespace segments to skip when inferring features and slices.")] +[LlmOutputAdvice("json-compact", "The .play document always goes to standard output verbatim; the format only shapes the summary and the diagnostics, and json-compact makes the diagnostics machine-readable on standard error.")] +public class GenerateScreenplayCommand : AsyncCommand +{ + readonly IScreenplayGeneration _generation; + readonly Func _standardOutput; + + /// + /// Initializes a new instance of the class. + /// + public GenerateScreenplayCommand() + : this(ScreenplayGenerations.Create(), Console.OpenStandardOutput) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The generation to produce the Screenplay with. + /// Opens the stream the document is written to when no file is given. + internal GenerateScreenplayCommand(IScreenplayGeneration generation, Func standardOutput) + { + _generation = generation; + _standardOutput = standardOutput; + } + + /// + protected override async Task ExecuteAsync(CommandContext context, GenerateScreenplaySettings settings, CancellationToken cancellationToken) + { + var format = settings.ResolveOutputFormat(); + var currentDirectory = Directory.GetCurrentDirectory(); + + // Standard output carries the document itself unless a file is given, so failures may not be reported + // through the ordinary panel — it would end up inside the redirected '.play' file. + var writesDocumentToStandardOutput = string.IsNullOrWhiteSpace(settings.File); + + var target = ScreenplayTargetResolver.Resolve(settings.Path, currentDirectory); + if (!target.IsResolved) + { + WriteError(format, writesDocumentToStandardOutput, target.Error!, target.Suggestion, ExitCodes.NotFoundCode); + return ExitCodes.NotFound; + } + + var generated = await _generation.Generate(target.Path!, settings.ToGenerationOptions(), cancellationToken); + ScreenplayDiagnosticsWriter.Write(format, generated.Diagnostics); + + var exitCode = ScreenplayDiagnostics.ExitCodeFor(generated.Diagnostics); + + // Standard output is the document itself, so a partial one cannot be written there — whatever consumes the + // redirect would take it for a complete document. A file can, and is: the diagnostics say what is missing. + if (writesDocumentToStandardOutput) + { + if (exitCode != ExitCodes.Success) + { + WriteError( + format, + true, + ErrorFor(generated), + "Pass --file to write the document that was generated anyway, or resolve the reported errors", + ExitCodes.ValidationErrorCode); + return exitCode; + } + + await using var stream = _standardOutput(); + await ScreenplayDocument.Write(stream, generated.Source, cancellationToken); + return ExitCodes.Success; + } + + var outputPath = ScreenplayDocument.ResolvePath(settings.File!, currentDirectory); + await ScreenplayDocument.WriteToFile(outputPath, generated.Source, cancellationToken); + + if (exitCode != ExitCodes.Success) + { + WriteError( + format, + false, + ErrorFor(generated), + $"The document was still written to {outputPath} — review it, then resolve the reported errors", + ExitCodes.ValidationErrorCode); + return exitCode; + } + + WriteResult(format, outputPath, target.Path!, generated); + return ExitCodes.Success; + } + + static string ErrorFor(GeneratedScreenplay generated) => + $"Screenplay generation reported {generated.Diagnostics.Count(diagnostic => diagnostic.Severity == ScreenplayDiagnosticSeverity.Error)} error(s)"; + + static void WriteError(string format, bool keepStandardOutputClean, string error, string? suggestion, string errorCode) + { + if (!keepStandardOutputClean || GoesToStandardError(format)) + { + OutputFormatter.WriteError(format, error, suggestion, errorCode); + return; + } + + Console.Error.WriteLine(); + Console.Error.WriteLine($"error: {error}"); + if (!string.IsNullOrWhiteSpace(suggestion)) + { + Console.Error.WriteLine($" -> {suggestion}"); + } + } + + /// + /// Mirrors the formats already reports on standard error. + /// + /// The resolved output format. + /// when the formatter writes errors to standard error. + static bool GoesToStandardError(string format) => + string.Equals(format, OutputFormats.Json, StringComparison.Ordinal) || + string.Equals(format, OutputFormats.JsonCompact, StringComparison.Ordinal) || + string.Equals(format, OutputFormats.Quiet, StringComparison.Ordinal); + + static void WriteResult(string format, string outputPath, string targetPath, GeneratedScreenplay generated) + { + if (string.Equals(format, OutputFormats.Quiet, StringComparison.Ordinal)) + { + Console.WriteLine(outputPath); + return; + } + + OutputFormatter.WriteObject( + format, + new + { + Path = outputPath, + Source = targetPath, + Lines = CountLines(generated.Source), + Diagnostics = generated.Diagnostics.Count + }, + result => + { + var content = new Markup( + $"[bold]{result.Path.EscapeMarkup()}[/]\n" + + $"Source: {result.Source.EscapeMarkup()}\n" + + $"Lines: {result.Lines}\n" + + $"Diagnostics: {result.Diagnostics}"); + var panel = new Panel(content) + .Header(" Screenplay generated ") + .Border(BoxBorder.Rounded) + .BorderStyle(new Style(OutputFormatter.Success)) + .Padding(1, 0); + + AnsiConsole.WriteLine(); + AnsiConsole.Write(panel); + AnsiConsole.MarkupLine($" [{OutputFormatter.Muted.ToMarkup()}]→ Run it in a local Stage sandbox with: cratis run[/]"); + }); + } + + static int CountLines(string source) => + source.Length == 0 ? 0 : source.AsSpan().TrimEnd('\n').Count('\n') + 1; +} diff --git a/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs b/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs new file mode 100644 index 0000000..f9f0cc0 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/GenerateScreenplaySettings.cs @@ -0,0 +1,54 @@ +// 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.Commands.Screenplay; + +/// +/// Settings for the screenplay generate command. +/// +public class GenerateScreenplaySettings : GlobalSettings +{ + /// + /// Gets or sets the solution, project, or folder to generate from. + /// + [CommandArgument(0, "[PATH]")] + [Description("Solution (.slnx, .sln), project (.csproj), or folder to read. Defaults to the current directory, searching upwards for a solution or project.")] + public string? Path { get; set; } + + /// + /// Gets or sets the file the generated Screenplay is written to. + /// + /// + /// Named --file rather than -o because -o is the global output format flag. + /// + [CommandOption("--file ")] + [Description("File to write the generated Screenplay to. Writes to standard output when not given.")] + public string? File { get; set; } + + /// + /// Gets or sets the domain the generated document belongs to. + /// + [CommandOption("--domain ")] + [Description("Name of the domain the generated document belongs to. Defaults to the assembly or root namespace of the project.")] + public string? Domain { get; set; } + + /// + /// Gets or sets the module every discovered feature is placed within. + /// + [CommandOption("--module ")] + [Description("Name of the module every discovered feature is placed within. Defaults to the domain.")] + public string? Module { get; set; } + + /// + /// Gets or sets the number of leading namespace segments to skip when inferring features and slices. + /// + [CommandOption("--skip-segments ")] + [Description("Number of leading namespace segments to skip when inferring features and slices.")] + public int? SkipSegments { get; set; } + + /// + /// Gets the generation options these settings describe. + /// + /// The . + public ScreenplayGenerationOptions ToGenerationOptions() => new(Domain, Module, SkipSegments); +} diff --git a/Source/Cli/Commands/Screenplay/GeneratedResourceSources.cs b/Source/Cli/Commands/Screenplay/GeneratedResourceSources.cs new file mode 100644 index 0000000..52ecd52 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/GeneratedResourceSources.cs @@ -0,0 +1,79 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Puts the strongly typed resource classes MSBuild generates into a project's intermediate output folder back into +/// the loaded project when the design-time build left them out. +/// +/// +/// makes the design-time build produce these itself, which also works for +/// a project that has never been built. This is the safety net for when it cannot — a read-only intermediate output +/// folder, or an MSBuild that no longer honours the hook. A previous ordinary build then still left the sources on +/// disk, and reading them back is enough to make the compilation whole. Only files the project does not already +/// compile are added, so this does nothing at all once the design-time build does its job. +/// +public static class GeneratedResourceSources +{ + const string Suffix = ".Designer.cs"; + + /// + /// Adds every generated resource source missing from the project. + /// + /// The project as the workspace loaded it. + /// The project, with the missing sources added as documents. + public static Project AddMissingTo(Project project) + { + var missing = MissingFrom( + project.CompilationOutputInfo.AssemblyPath, + project.Documents.Select(document => document.FilePath)); + + var solution = project.Solution; + foreach (var file in missing) + { + solution = solution.AddDocument( + DocumentId.CreateNewId(project.Id), + Path.GetFileName(file), + SourceText.From(File.ReadAllText(file)), + filePath: file); + } + + return solution.GetProject(project.Id) ?? project; + } + + /// + /// Finds the generated resource sources sitting next to the intermediate assembly that are not compiled already. + /// + /// The path of the assembly the project compiles into its intermediate output folder. + /// The paths of the files the project already compiles. + /// The paths of the missing sources, ordered. + public static IReadOnlyList MissingFrom(string? intermediateAssemblyPath, IEnumerable compiledPaths) + { + if (string.IsNullOrWhiteSpace(intermediateAssemblyPath)) + { + return []; + } + + var folder = Path.GetDirectoryName(intermediateAssemblyPath); + if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder)) + { + return []; + } + + var compiled = compiledPaths + .Where(path => !string.IsNullOrEmpty(path)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + return + [ + .. Directory + .EnumerateFiles(folder, $"*{Suffix}", SearchOption.TopDirectoryOnly) + .Where(file => !compiled.Contains(file)) + .Order(StringComparer.Ordinal) + ]; + } +} diff --git a/Source/Cli/Commands/Screenplay/GeneratedScreenplay.cs b/Source/Cli/Commands/Screenplay/GeneratedScreenplay.cs new file mode 100644 index 0000000..82353ba --- /dev/null +++ b/Source/Cli/Commands/Screenplay/GeneratedScreenplay.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.Commands.Screenplay; + +/// +/// Represents the outcome of generating a Screenplay document from source code. +/// +/// The generated .play source; empty when generation failed outright. +/// Everything the generator could not fully express, in the order it was reported. +public record GeneratedScreenplay(string Source, IReadOnlyList Diagnostics) +{ + /// + /// Gets an outcome carrying no source and a single error diagnostic. + /// + /// The stable diagnostic code. + /// The human readable description. + /// The failed . + public static GeneratedScreenplay Failed(string code, string message) => + new(string.Empty, [new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, code, message, null)]); +} diff --git a/Source/Cli/Commands/Screenplay/IScreenplayGeneration.cs b/Source/Cli/Commands/Screenplay/IScreenplayGeneration.cs new file mode 100644 index 0000000..40239f1 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/IScreenplayGeneration.cs @@ -0,0 +1,24 @@ +// 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.Commands.Screenplay; + +/// +/// Defines a system that generates a Screenplay document from the source code of a Cratis Arc application. +/// +/// +/// This is the seam between the CLI and the Cratis.Arc.Screenplay generator. Everything the CLI does around +/// generation — resolving the target, writing the document, reporting diagnostics — is expressed against this +/// interface so that it stays independent of how a compilation is obtained. +/// +public interface IScreenplayGeneration +{ + /// + /// Generates the Screenplay document describing the application in the given solution or project. + /// + /// The full path of the solution or project file to read. + /// The options that shape the generated document. + /// Cancellation token. + /// The holding the source and any diagnostics. + Task Generate(string targetPath, ScreenplayGenerationOptions options, CancellationToken cancellationToken); +} diff --git a/Source/Cli/Commands/Screenplay/IScreenplayValidation.cs b/Source/Cli/Commands/Screenplay/IScreenplayValidation.cs new file mode 100644 index 0000000..014399a --- /dev/null +++ b/Source/Cli/Commands/Screenplay/IScreenplayValidation.cs @@ -0,0 +1,23 @@ +// 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.Commands.Screenplay; + +/// +/// Defines a system that compiles Screenplay documents and reports what the compiler found. +/// +/// +/// This is the seam between the CLI and the Cratis.Screenplay compiler, mirroring +/// . Everything the CLI does around validation — resolving the path, reporting +/// diagnostics, deciding the exit code — is expressed against this interface so that it stays independent of how a +/// document is compiled. +/// +public interface IScreenplayValidation +{ + /// + /// Compiles the Screenplay document, or every document beneath the folder, at the given path. + /// + /// The full path of a .play file, or of a folder to search. + /// The holding what was compiled and any diagnostics. + ValidatedScreenplay Validate(string targetPath); +} diff --git a/Source/Cli/Commands/Screenplay/LoadedCompilation.cs b/Source/Cli/Commands/Screenplay/LoadedCompilation.cs new file mode 100644 index 0000000..067973c --- /dev/null +++ b/Source/Cli/Commands/Screenplay/LoadedCompilation.cs @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Represents the outcome of loading a solution or project into a Roslyn compilation. +/// +/// The compilation to generate from; when loading failed. +/// The name of the project the compilation came from; empty when loading failed. +/// Anything worth reporting about the load itself. +public record LoadedCompilation(Compilation? Compilation, string ProjectName, IReadOnlyList Diagnostics) +{ + /// + /// Gets a failed outcome carrying a single error diagnostic. + /// + /// The stable diagnostic code. + /// The human readable description. + /// The solution or project the failure applies to. + /// Diagnostics gathered before the failure. + /// The failed . + public static LoadedCompilation Failed(string code, string message, string? location, IEnumerable? warnings = null) => + new( + null, + string.Empty, + [.. warnings ?? [], new ScreenplayDiagnostic(ScreenplayDiagnosticSeverity.Error, code, message, location)]); +} diff --git a/Source/Cli/Commands/Screenplay/PlayFileTargetResolver.cs b/Source/Cli/Commands/Screenplay/PlayFileTargetResolver.cs new file mode 100644 index 0000000..c91045b --- /dev/null +++ b/Source/Cli/Commands/Screenplay/PlayFileTargetResolver.cs @@ -0,0 +1,55 @@ +// 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.Commands.Screenplay; + +/// +/// Resolves the Screenplay document, or the folder of documents, that is validated. +/// +/// +/// Unlike nothing is discovered by searching upwards — a folder is a perfectly +/// good answer on its own, because every .play file beneath it is compiled. +/// +public static class PlayFileTargetResolver +{ + /// + /// The file extension that identifies a Screenplay document. + /// + public const string Extension = ".play"; + + /// + /// Resolves the document or folder to compile. + /// + /// The path given on the command line — a .play file or a folder. uses . + /// The directory relative paths are resolved against. + /// The describing the outcome. + public static ScreenplayTarget Resolve(string? path, string currentDirectory) + { + var candidate = string.IsNullOrWhiteSpace(path) + ? currentDirectory + : Path.GetFullPath(path, currentDirectory); + + if (File.Exists(candidate)) + { + return IsPlayFile(candidate) + ? ScreenplayTarget.Resolved(candidate) + : ScreenplayTarget.Unresolved( + $"'{candidate}' is not a Screenplay ({Extension}) file", + $"Point the command at a {Extension} file, or at the folder holding one"); + } + + return Directory.Exists(candidate) + ? ScreenplayTarget.Resolved(candidate) + : ScreenplayTarget.Unresolved( + $"'{candidate}' does not exist", + $"Point the command at an existing {Extension} file or folder"); + } + + /// + /// Determines whether the given file is a Screenplay document. + /// + /// The file path to check. + /// when the file is a .play file. + public static bool IsPlayFile(string path) => + string.Equals(Path.GetExtension(path), Extension, StringComparison.OrdinalIgnoreCase); +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs b/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs new file mode 100644 index 0000000..5420ee5 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayCompilationLoader.cs @@ -0,0 +1,118 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Runtime.CompilerServices; +using Microsoft.Build.Locator; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.MSBuild; + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Loads a solution or project into the Roslyn compilation the Screenplay generator reads. +/// +/// +/// The Cratis.Arc.Screenplay generator deliberately never loads an MSBuild workspace — it takes a +/// and nothing else. Doing the workspace work here keeps that seam intact and makes the +/// generator equally usable from an MSBuild task, an analyzer, or a spec that builds a compilation from strings. +/// +public static class ScreenplayCompilationLoader +{ + static readonly Lock _registration = new(); + + /// + /// Registers the .NET SDK MSBuild instance with the process. + /// + /// + /// This has to happen before any MSBuild type is touched, which is why every member that does touch one is + /// marked as not inlinable — the JIT would otherwise resolve those types while this method is still running. + /// + public static void RegisterMSBuild() + { + lock (_registration) + { + if (!MSBuildLocator.IsRegistered) + { + MSBuildLocator.RegisterDefaults(); + } + } + } + + /// + /// Loads the given solution or project and returns the compilation to generate from. + /// + /// The full path of the solution or project file. + /// Cancellation token. + /// The describing the outcome. + [MethodImpl(MethodImplOptions.NoInlining)] + public static async Task Load(string targetPath, CancellationToken cancellationToken) + { + RegisterMSBuild(); + return await LoadWithWorkspace(targetPath, cancellationToken); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static async Task LoadWithWorkspace(string targetPath, CancellationToken cancellationToken) + { + var failures = new List(); + var failureLock = new Lock(); + using var resources = DesignTimeResourceGeneration.Create(); + using var workspace = MSBuildWorkspace.Create(resources.GlobalProperties); + using var subscription = workspace.RegisterWorkspaceFailedHandler(args => + { + lock (failureLock) + { + failures.Add(new ScreenplayDiagnostic( + ScreenplayDiagnosticSeverity.Warning, + ScreenplayDiagnosticCodes.WorkspaceFailure, + args.Diagnostic.Message, + targetPath)); + } + }); + + var projects = ScreenplayTargetResolver.IsSolution(targetPath) + ? (await workspace.OpenSolutionAsync(targetPath, cancellationToken: cancellationToken)).Projects + : [await workspace.OpenProjectAsync(targetPath, cancellationToken: cancellationToken)]; + + var byName = projects + .Where(project => project.Language == LanguageNames.CSharp) + .GroupBy(project => project.Name, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal); + + var candidates = byName.Values + .Select(project => new ScreenplayProjectCandidate(project.Name, IsExecutable(project))) + .ToArray(); + + var narrowed = ScreenplayProjectSelection.Narrow(candidates); + if (narrowed.Count == 0) + { + return LoadedCompilation.Failed( + ScreenplayDiagnosticCodes.NoProject, + $"No C# project to generate from was found in '{targetPath}'", + targetPath, + failures); + } + + if (narrowed.Count > 1) + { + return LoadedCompilation.Failed( + ScreenplayDiagnosticCodes.AmbiguousProject, + $"'{targetPath}' holds {narrowed.Count} candidate projects ({string.Join(", ", narrowed.Select(candidate => candidate.Name))}) — pass the project to generate from", + targetPath, + failures); + } + + var selected = GeneratedResourceSources.AddMissingTo(byName[narrowed[0].Name]); + var compilation = await selected.GetCompilationAsync(cancellationToken); + return compilation is null + ? LoadedCompilation.Failed( + ScreenplayDiagnosticCodes.NoCompilation, + $"No compilation could be created for '{selected.Name}'", + selected.FilePath ?? targetPath, + failures) + : new LoadedCompilation(compilation, selected.Name, failures); + } + + static bool IsExecutable(Project project) => + project.CompilationOptions?.OutputKind is OutputKind.ConsoleApplication or OutputKind.WindowsApplication; +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDiagnostic.cs b/Source/Cli/Commands/Screenplay/ScreenplayDiagnostic.cs new file mode 100644 index 0000000..2bbae8b --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayDiagnostic.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.Commands.Screenplay; + +/// +/// Represents something a Screenplay document does not fully express — a construct the generator could not +/// represent, or a problem the compiler found in a document that already exists. +/// +/// How severe the diagnostic is. +/// The stable diagnostic code, for example SP0001; empty when the reporting system assigns none. +/// The human readable description. +/// The slice, artifact, or file the diagnostic points at; when it applies to the whole document. +public record ScreenplayDiagnostic( + ScreenplayDiagnosticSeverity Severity, + string Code, + string Message, + string? Location); diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs new file mode 100644 index 0000000..9b77be5 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticCodes.cs @@ -0,0 +1,34 @@ +// 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.Commands.Screenplay; + +/// +/// The diagnostic codes the CLI itself reports while preparing a Screenplay generation. +/// +/// +/// These sit alongside the codes the Cratis.Arc.Screenplay generator reports and use a distinct prefix so the +/// two can never be confused for one another. +/// +public static class ScreenplayDiagnosticCodes +{ + /// + /// No project in the solution could be generated from. + /// + public const string NoProject = "CLI0001"; + + /// + /// The solution holds more than one candidate project and the choice is ambiguous. + /// + public const string AmbiguousProject = "CLI0002"; + + /// + /// MSBuild reported a problem while loading the solution or project. + /// + public const string WorkspaceFailure = "CLI0003"; + + /// + /// The project loaded but Roslyn could not produce a compilation for it. + /// + public const string NoCompilation = "CLI0004"; +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticSeverity.cs b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticSeverity.cs new file mode 100644 index 0000000..43f2b04 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticSeverity.cs @@ -0,0 +1,25 @@ +// 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.Commands.Screenplay; + +/// +/// Represents how severe a reported during generation is. +/// +public enum ScreenplayDiagnosticSeverity +{ + /// + /// Informational — the generated document is complete; the diagnostic only adds context. + /// + Information = 0, + + /// + /// A construct was recognized but could not be fully represented in the generated document. + /// + Warning = 1, + + /// + /// Generation failed for the construct; the generated document does not describe the source faithfully. + /// + Error = 2 +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDiagnostics.cs b/Source/Cli/Commands/Screenplay/ScreenplayDiagnostics.cs new file mode 100644 index 0000000..954eb04 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayDiagnostics.cs @@ -0,0 +1,46 @@ +// 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.Commands.Screenplay; + +/// +/// Orders generation diagnostics and turns them into an exit code. +/// +public static class ScreenplayDiagnostics +{ + /// + /// Orders diagnostics by descending severity and then deterministically within each severity. + /// + /// The diagnostics to order. + /// The ordered diagnostics. + public static IReadOnlyList Order(IEnumerable diagnostics) => + [.. diagnostics + .OrderByDescending(diagnostic => diagnostic.Severity) + .ThenBy(diagnostic => diagnostic.Code, StringComparer.Ordinal) + .ThenBy(diagnostic => diagnostic.Location ?? string.Empty, StringComparer.Ordinal) + .ThenBy(diagnostic => diagnostic.Message, StringComparer.Ordinal)]; + + /// + /// Groups diagnostics by severity, most severe first, keeping each group deterministically ordered. + /// + /// The diagnostics to group. + /// The groups, most severe first. + public static IReadOnlyList> GroupBySeverity(IEnumerable diagnostics) => + [.. Order(diagnostics).GroupBy(diagnostic => diagnostic.Severity)]; + + /// + /// Determines whether any diagnostic is an error. + /// + /// The diagnostics to inspect. + /// when at least one diagnostic is an error. + public static bool HasErrors(IEnumerable diagnostics) => + diagnostics.Any(diagnostic => diagnostic.Severity == ScreenplayDiagnosticSeverity.Error); + + /// + /// Resolves the exit code for a set of diagnostics — non-zero as soon as one of them is an error. + /// + /// The diagnostics to inspect. + /// The exit code. + public static int ExitCodeFor(IEnumerable diagnostics) => + HasErrors(diagnostics) ? ExitCodes.ValidationError : ExitCodes.Success; +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticsWriter.cs b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticsWriter.cs new file mode 100644 index 0000000..9952734 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayDiagnosticsWriter.cs @@ -0,0 +1,116 @@ +// 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.Commands.Screenplay; + +/// +/// Renders generation diagnostics to standard error, grouped by severity. +/// +/// +/// Diagnostics always go to standard error. The generated document may be on standard output, and mixing the two +/// would corrupt it — cratis screenplay generate > MyApp.play has to keep working. +/// +public static class ScreenplayDiagnosticsWriter +{ + /// + /// Writes the diagnostics to standard error in the given output format. + /// + /// The resolved output format. + /// The diagnostics to write. + public static void Write(string format, IEnumerable diagnostics) + { + var groups = ScreenplayDiagnostics.GroupBySeverity(diagnostics); + if (groups.Count == 0) + { + return; + } + + if (IsMachineReadable(format)) + { + WriteJson(format, groups); + return; + } + + WriteText(groups); + } + + /// + /// Gets the label used for a severity in text output. + /// + /// The severity to label. + /// The label. + public static string LabelFor(ScreenplayDiagnosticSeverity severity) => severity switch + { + ScreenplayDiagnosticSeverity.Error => "error", + ScreenplayDiagnosticSeverity.Warning => "warning", + _ => "info" + }; + + /// + /// Gets the heading used for a group of diagnostics of the same severity. + /// + /// The severity the group holds. + /// The heading. + public static string GroupHeadingFor(ScreenplayDiagnosticSeverity severity) => severity switch + { + ScreenplayDiagnosticSeverity.Error => "errors", + ScreenplayDiagnosticSeverity.Warning => "warnings", + _ => "information" + }; + + /// + /// Builds the line a single diagnostic is written as in text output. + /// + /// The diagnostic to write. + /// The line. + /// + /// The code and the location are both left out when they are absent — the compiler behind + /// screenplay validate assigns no codes, and a diagnostic about a whole document has no location. + /// + public static string LineFor(ScreenplayDiagnostic diagnostic) + { + var code = string.IsNullOrWhiteSpace(diagnostic.Code) ? string.Empty : $" {diagnostic.Code}"; + var location = string.IsNullOrWhiteSpace(diagnostic.Location) ? string.Empty : $" [{diagnostic.Location}]"; + return $" {LabelFor(diagnostic.Severity)}{code}:{location} {diagnostic.Message}"; + } + + static bool IsMachineReadable(string format) => + string.Equals(format, OutputFormats.Json, StringComparison.Ordinal) || + string.Equals(format, OutputFormats.JsonCompact, StringComparison.Ordinal) || + string.Equals(format, OutputFormats.JsonQuiet, StringComparison.Ordinal) || + string.Equals(format, OutputFormats.Quiet, StringComparison.Ordinal); + + static void WriteJson(string format, IEnumerable> groups) + { + var options = string.Equals(format, OutputFormats.Json, StringComparison.Ordinal) + ? OutputFormatter.IndentedJsonSerializerOptions + : OutputFormatter.JsonSerializerOptions; + + var payload = new + { + Diagnostics = groups.SelectMany(group => group.Select(diagnostic => new + { + Severity = LabelFor(diagnostic.Severity), + diagnostic.Code, + diagnostic.Message, + diagnostic.Location + })) + }; + + Console.Error.WriteLine(JsonSerializer.Serialize(payload, options)); + } + + static void WriteText(IEnumerable> groups) + { + foreach (var group in groups) + { + Console.Error.WriteLine(); + Console.Error.WriteLine($"{GroupHeadingFor(group.Key)} ({group.Count()}):"); + + foreach (var diagnostic in group) + { + Console.Error.WriteLine(LineFor(diagnostic)); + } + } + } +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayDocument.cs b/Source/Cli/Commands/Screenplay/ScreenplayDocument.cs new file mode 100644 index 0000000..d5c496e --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayDocument.cs @@ -0,0 +1,62 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text; + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Writes a generated Screenplay document without altering a single byte of it. +/// +/// +/// The generator produces source that ends with exactly one newline, and round-tripping a .play file has to +/// be byte identical. Everything here therefore writes raw UTF-8 without a byte order mark and never appends, +/// trims, or translates a line ending. +/// +public static class ScreenplayDocument +{ + static readonly UTF8Encoding _encoding = new(encoderShouldEmitUTF8Identifier: false); + + /// + /// Resolves the full path the document is written to. + /// + /// The file given on the command line. + /// The directory relative paths are resolved against. + /// The full path of the file to write. + public static string ResolvePath(string file, string currentDirectory) => Path.GetFullPath(file, currentDirectory); + + /// + /// Writes the document to a file, creating the folder it lives in when needed. + /// + /// The full path to write to. + /// The generated .play source. + /// Cancellation token. + /// Awaitable task. + public static async Task WriteToFile(string path, string source, CancellationToken cancellationToken) + { + var folder = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(folder)) + { + Directory.CreateDirectory(folder); + } + + await File.WriteAllBytesAsync(path, _encoding.GetBytes(source), cancellationToken); + } + + /// + /// Writes the document to a stream as raw UTF-8. + /// + /// The stream to write to. It is left open for the caller to dispose. + /// The generated .play source. + /// Cancellation token. + /// Awaitable task. + /// + /// Writing bytes to the raw standard output stream rather than through is deliberate: + /// the console writer applies its own encoding and would corrupt anything outside its code page. + /// + public static async Task Write(Stream stream, string source, CancellationToken cancellationToken) + { + await stream.WriteAsync(_encoding.GetBytes(source), cancellationToken); + await stream.FlushAsync(cancellationToken); + } +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayGenerationOptions.cs b/Source/Cli/Commands/Screenplay/ScreenplayGenerationOptions.cs new file mode 100644 index 0000000..c061c56 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayGenerationOptions.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.Commands.Screenplay; + +/// +/// Represents the options that shape the generated Screenplay document. +/// +/// The domain the document belongs to; lets the generator derive it from the compilation. +/// The module every discovered feature is placed within; falls back to the domain. +/// The number of leading namespace segments to skip when inferring features and slices; uses the generator default. +public record ScreenplayGenerationOptions(string? Domain, string? Module, int? SegmentsToSkip) +{ + /// + /// Gets the options that leave every choice to the generator. + /// + public static ScreenplayGenerationOptions Default { get; } = new(null, null, null); +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayGenerations.cs b/Source/Cli/Commands/Screenplay/ScreenplayGenerations.cs new file mode 100644 index 0000000..c698c6b --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayGenerations.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.Commands.Screenplay; + +/// +/// Creates the this build of the CLI can offer. +/// +public static class ScreenplayGenerations +{ + /// + /// Creates the generation to use. + /// + /// The to generate with. + public static IScreenplayGeneration Create() => new ArcScreenplayGeneration(); +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayProjectCandidate.cs b/Source/Cli/Commands/Screenplay/ScreenplayProjectCandidate.cs new file mode 100644 index 0000000..69ca5dc --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayProjectCandidate.cs @@ -0,0 +1,11 @@ +// 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.Commands.Screenplay; + +/// +/// Represents a project in a loaded solution that a Screenplay could be generated from. +/// +/// The project name. +/// Whether the project produces an executable rather than a library. +public record ScreenplayProjectCandidate(string Name, bool IsExecutable); diff --git a/Source/Cli/Commands/Screenplay/ScreenplayProjectSelection.cs b/Source/Cli/Commands/Screenplay/ScreenplayProjectSelection.cs new file mode 100644 index 0000000..8be725d --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayProjectSelection.cs @@ -0,0 +1,52 @@ +// 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.Commands.Screenplay; + +/// +/// Selects which project of a loaded solution the Screenplay is generated from. +/// +/// +/// A Screenplay describes one application, so a solution has to narrow down to a single project. Spec projects are +/// dropped, and when any project produces an executable the libraries are dropped too — an Arc application is the +/// executable. Anything still ambiguous is reported rather than guessed at. +/// +public static class ScreenplayProjectSelection +{ + static readonly string[] _specSuffixes = [".Specs", ".Specifications", ".Tests", ".Test", ".IntegrationTests"]; + + /// + /// Selects the single project to generate from. + /// + /// The projects found in the solution. + /// The name of the selected project, or when the choice is ambiguous or there is nothing to choose from. + public static string? Select(IEnumerable candidates) + { + var remaining = Narrow(candidates); + return remaining.Count == 1 ? remaining[0].Name : null; + } + + /// + /// Narrows the projects down to the ones a Screenplay could reasonably be generated from. + /// + /// The projects found in the solution. + /// The remaining projects, ordered by name. + public static IReadOnlyList Narrow(IEnumerable candidates) + { + var withoutSpecs = candidates + .Where(candidate => !IsSpecProject(candidate.Name)) + .OrderBy(candidate => candidate.Name, StringComparer.Ordinal) + .ToArray(); + + var executables = withoutSpecs.Where(candidate => candidate.IsExecutable).ToArray(); + return executables.Length > 0 ? executables : withoutSpecs; + } + + /// + /// Determines whether the given project name identifies a spec or test project. + /// + /// The project name. + /// when the project holds specs or tests. + public static bool IsSpecProject(string name) => + Array.Exists(_specSuffixes, suffix => name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)); +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayTarget.cs b/Source/Cli/Commands/Screenplay/ScreenplayTarget.cs new file mode 100644 index 0000000..58bff57 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayTarget.cs @@ -0,0 +1,34 @@ +// 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.Commands.Screenplay; + +/// +/// Represents the outcome of resolving the path a Screenplay command works on — the solution or project one is +/// generated from, or the document one is validated in. +/// +/// The full path that was resolved; when resolution failed. +/// The reason resolution failed; when it succeeded. +/// A hint for resolving the failure; when there is none. +public record ScreenplayTarget(string? Path, string? Error, string? Suggestion) +{ + /// + /// Gets a value indicating whether a solution or project was resolved. + /// + public bool IsResolved => Path is not null; + + /// + /// Gets a resolved target. + /// + /// The full path of the resolved solution or project file. + /// The resolved . + public static ScreenplayTarget Resolved(string path) => new(path, null, null); + + /// + /// Gets an unresolved target carrying the reason and a hint. + /// + /// The reason resolution failed. + /// A hint for resolving the failure. + /// The unresolved . + public static ScreenplayTarget Unresolved(string error, string suggestion) => new(null, error, suggestion); +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayTargetResolver.cs b/Source/Cli/Commands/Screenplay/ScreenplayTargetResolver.cs new file mode 100644 index 0000000..d9639bc --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayTargetResolver.cs @@ -0,0 +1,114 @@ +// 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.Commands.Screenplay; + +/// +/// Resolves the solution or project file a Screenplay is generated from. +/// +public static class ScreenplayTargetResolver +{ + static readonly string[] _extensions = [".slnx", ".sln", ".csproj"]; + + /// + /// Gets the file extensions that identify a solution or project the generator can read, in discovery order. + /// + public static IReadOnlyList Extensions => _extensions; + + /// + /// Resolves the solution or project file to read. + /// + /// The path given on the command line — a solution file, a project file, or a folder. uses . + /// The directory relative paths are resolved against and discovery starts from. + /// The describing the outcome. + public static ScreenplayTarget Resolve(string? path, string currentDirectory) + { + var candidate = string.IsNullOrWhiteSpace(path) + ? currentDirectory + : Path.GetFullPath(path, currentDirectory); + + if (File.Exists(candidate)) + { + return IsSupportedFile(candidate) + ? ScreenplayTarget.Resolved(candidate) + : ScreenplayTarget.Unresolved( + $"'{candidate}' is not a solution or project file", + $"Point the command at a {string.Join(", ", _extensions)} file, or at the folder holding one"); + } + + if (!Directory.Exists(candidate)) + { + return ScreenplayTarget.Unresolved( + $"'{candidate}' does not exist", + "Point the command at an existing solution file, project file, or folder"); + } + + return Discover(candidate); + } + + /// + /// Determines whether the given file is a solution or project file the generator can read. + /// + /// The file path to check. + /// when the file is supported. + public static bool IsSupportedFile(string path) => + _extensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase); + + /// + /// Determines whether the given file is a solution file rather than a project file. + /// + /// The file path to check. + /// when the file is a solution. + public static bool IsSolution(string path) + { + var extension = Path.GetExtension(path); + return string.Equals(extension, ".sln", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".slnx", StringComparison.OrdinalIgnoreCase); + } + + static ScreenplayTarget Discover(string directory) + { + var current = new DirectoryInfo(directory); + while (current is not null) + { + var found = FindIn(current); + if (found is not null) + { + return found; + } + + current = current.Parent; + } + + return ScreenplayTarget.Unresolved( + $"No solution or project file found in '{directory}' or any parent folder", + $"Run the command from a folder holding a {string.Join(", ", _extensions)} file, or pass the path to one"); + } + + static ScreenplayTarget? FindIn(DirectoryInfo directory) + { + foreach (var extension in _extensions) + { + var matches = directory + .GetFiles($"*{extension}", SearchOption.TopDirectoryOnly) + .Select(file => file.FullName) + .Where(file => string.Equals(Path.GetExtension(file), extension, StringComparison.OrdinalIgnoreCase)) + .Order(StringComparer.Ordinal) + .ToArray(); + + if (matches.Length == 1) + { + return ScreenplayTarget.Resolved(matches[0]); + } + + if (matches.Length > 1) + { + return ScreenplayTarget.Unresolved( + $"Found {matches.Length} {extension} files in '{directory.FullName}'", + $"Pass the one to read: {string.Join(", ", matches.Select(Path.GetFileName))}"); + } + } + + return null; + } +} diff --git a/Source/Cli/Commands/Screenplay/ScreenplayValidation.cs b/Source/Cli/Commands/Screenplay/ScreenplayValidation.cs new file mode 100644 index 0000000..3390222 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ScreenplayValidation.cs @@ -0,0 +1,64 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Screenplay; +using Cratis.Screenplay.Diagnostics; +using Cratis.Screenplay.Files; + +namespace Cratis.Cli.Commands.Screenplay; + +/// +/// Compiles Screenplay documents with the Cratis.Screenplay compiler. +/// +/// +/// This is the only place in the CLI that knows the compiler exists. Everything else is expressed against +/// , and the diagnostics the compiler reports are translated into the same shape +/// generation reports, so both commands read identically. +/// +/// Compiles every document beneath a folder. +/// Compiles the source of a single document. +public sealed class ScreenplayValidation(IPlayFileCompiler playFileCompiler, IScreenplayCompiler compiler) : IScreenplayValidation +{ + /// + /// Initializes a new instance of the class with the default compilers. + /// + public ScreenplayValidation() + : this(new PlayFileCompiler(), new ScreenplayCompiler()) + { + } + + /// + public ValidatedScreenplay Validate(string targetPath) + { + var compilations = File.Exists(targetPath) + ? [CompileFile(targetPath)] + : playFileCompiler.CompileIn(targetPath).ToArray(); + + return new( + compilations.Length, + [.. compilations.SelectMany(compilation => compilation.Result.Diagnostics.Select(diagnostic => Map(compilation.File, diagnostic)))]); + } + + /// + /// Translates a compiler diagnostic into the shape the CLI reports. + /// + /// The file the diagnostic belongs to. + /// The diagnostic the compiler reported. + /// The . + /// + /// The compiler does not assign codes, so the code is left empty. The location carries the file and the position + /// within it, in the file(line,column) form editors and build logs already understand. + /// + static ScreenplayDiagnostic Map(PlayFile file, Diagnostic diagnostic) => + new( + (ScreenplayDiagnosticSeverity)(int)diagnostic.Severity, + string.Empty, + diagnostic.Message, + $"{file.RelativePath}({diagnostic.Location.Line},{diagnostic.Location.Column})"); + + PlayFileCompilation CompileFile(string path) + { + var source = File.ReadAllText(path); + return new(new PlayFile(path, Path.GetFileName(path)), source, compiler.Compile(source)); + } +} diff --git a/Source/Cli/Commands/Screenplay/ValidateScreenplayCommand.cs b/Source/Cli/Commands/Screenplay/ValidateScreenplayCommand.cs new file mode 100644 index 0000000..84a5be9 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ValidateScreenplayCommand.cs @@ -0,0 +1,113 @@ +// 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.Commands.Screenplay; + +/// +/// Compiles Cratis Screenplay (.play) documents and reports everything the compiler found, whatever wrote +/// them — screenplay generate, prologue, or a person. +/// +[LlmDescription("Compiles Cratis Screenplay (.play) documents and reports every diagnostic the compiler produces. Takes a .play file, or a folder in which case every .play file beneath it is compiled. Nothing needs to be running. Diagnostics go to standard error, grouped by severity; the command exits with a validation error when any of them is an error.")] +[CliCommand("validate", "Validate Screenplay (.play) documents", Branch = typeof(ScreenplayBranch))] +[CliExample("screenplay", "validate")] +[CliExample("screenplay", "validate", "./MyApp.play")] +[CliExample("screenplay", "validate", "./plays")] +[LlmOption("[PATH]", "string", "Screenplay (.play) file, or folder to compile every .play file beneath. Defaults to the current directory.")] +[LlmOutputAdvice("json-compact", "The summary goes to standard output and the diagnostics to standard error; json-compact makes both machine-readable.")] +public class ValidateScreenplayCommand : Command +{ + readonly IScreenplayValidation _validation; + + /// + /// Initializes a new instance of the class. + /// + public ValidateScreenplayCommand() + : this(new ScreenplayValidation()) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The validation to compile the documents with. + internal ValidateScreenplayCommand(IScreenplayValidation validation) + { + _validation = validation; + } + + /// + protected override int Execute(CommandContext context, ValidateScreenplaySettings settings, CancellationToken cancellationToken) + { + var format = settings.ResolveOutputFormat(); + + var target = PlayFileTargetResolver.Resolve(settings.Path, Directory.GetCurrentDirectory()); + if (!target.IsResolved) + { + OutputFormatter.WriteError(format, target.Error!, target.Suggestion, ExitCodes.NotFoundCode); + return ExitCodes.NotFound; + } + + var validated = _validation.Validate(target.Path!); + if (validated.FileCount == 0) + { + // Silently succeeding on a folder holding nothing turns the command into a no-op in CI, which is + // exactly where it is trusted the most. + OutputFormatter.WriteError( + format, + $"No Screenplay ({PlayFileTargetResolver.Extension}) files found in '{target.Path}'", + $"Point the command at a {PlayFileTargetResolver.Extension} file, or at a folder holding one", + ExitCodes.NotFoundCode); + return ExitCodes.NotFound; + } + + ScreenplayDiagnosticsWriter.Write(format, validated.Diagnostics); + + var exitCode = ScreenplayDiagnostics.ExitCodeFor(validated.Diagnostics); + if (exitCode != ExitCodes.Success) + { + var errors = validated.Diagnostics.Count(diagnostic => diagnostic.Severity == ScreenplayDiagnosticSeverity.Error); + OutputFormatter.WriteError( + format, + $"Validation reported {errors} error(s)", + "Fix the reported errors in the Screenplay document", + ExitCodes.ValidationErrorCode); + return exitCode; + } + + WriteResult(format, target.Path!, validated); + return ExitCodes.Success; + } + + static void WriteResult(string format, string targetPath, ValidatedScreenplay validated) + { + if (string.Equals(format, OutputFormats.Quiet, StringComparison.Ordinal)) + { + Console.WriteLine(targetPath); + return; + } + + OutputFormatter.WriteObject( + format, + new + { + Path = targetPath, + Files = validated.FileCount, + Diagnostics = validated.Diagnostics.Count + }, + result => + { + var content = new Markup( + $"[bold]{result.Path.EscapeMarkup()}[/]\n" + + $"Files: {result.Files}\n" + + $"Diagnostics: {result.Diagnostics}"); + var panel = new Panel(content) + .Header(" Valid ") + .Border(BoxBorder.Rounded) + .BorderStyle(new Style(OutputFormatter.Success)) + .Padding(1, 0); + + AnsiConsole.WriteLine(); + AnsiConsole.Write(panel); + }); + } +} diff --git a/Source/Cli/Commands/Screenplay/ValidateScreenplaySettings.cs b/Source/Cli/Commands/Screenplay/ValidateScreenplaySettings.cs new file mode 100644 index 0000000..54ed758 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ValidateScreenplaySettings.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.Commands.Screenplay; + +/// +/// Settings for the screenplay validate command. +/// +public class ValidateScreenplaySettings : GlobalSettings +{ + /// + /// Gets or sets the document or folder to compile. + /// + [CommandArgument(0, "[PATH]")] + [Description("Screenplay (.play) file, or folder to compile every .play file beneath. Defaults to the current directory.")] + public string? Path { get; set; } +} diff --git a/Source/Cli/Commands/Screenplay/ValidatedScreenplay.cs b/Source/Cli/Commands/Screenplay/ValidatedScreenplay.cs new file mode 100644 index 0000000..3c1cab0 --- /dev/null +++ b/Source/Cli/Commands/Screenplay/ValidatedScreenplay.cs @@ -0,0 +1,11 @@ +// 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.Commands.Screenplay; + +/// +/// Represents the outcome of compiling one or more Screenplay documents. +/// +/// The number of .play files that were compiled. +/// Everything the compiler reported, across every file. +public record ValidatedScreenplay(int FileCount, IReadOnlyList Diagnostics); diff --git a/Source/Cli/Registration/ScreenplayBranch.cs b/Source/Cli/Registration/ScreenplayBranch.cs new file mode 100644 index 0000000..f9fc9cd --- /dev/null +++ b/Source/Cli/Registration/ScreenplayBranch.cs @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#pragma warning disable RCS1251, SA1502 // Marker type is intentionally empty + +namespace Cratis.Cli.Registration; + +/// +/// Authoring and generation of Cratis Screenplay (.play) documents. +/// +[CliBranch("screenplay", "Work with Cratis Screenplay (.play) documents. Generate a Screenplay from source code and validate the documents you have.")] +public static class ScreenplayBranch;