diff --git a/docs/getting-started/dotskit.md b/docs/getting-started/dotskit.md index d3b2409..8b38881 100644 --- a/docs/getting-started/dotskit.md +++ b/docs/getting-started/dotskit.md @@ -29,6 +29,9 @@ dotskit new -S Billing --Storage true # in the solution: a flag for a service it has dotskit new -S Orders --MongoDB true + +# in a solution made without dotskit: describe it in its manifest +dotskit init ``` `dotskit new` reads from the folder whether to make a solution or to add to one: it looks for @@ -80,6 +83,43 @@ services has it. The solution's names, `--Database`, `--Deploy`, `--TestFramewor `--GitHubCiCd` are set by the first command. A later command takes them from the manifest; a command that gives one of them another value is refused. +## A solution made without dotskit + +`dotskit new` works from the manifest. A solution made with the template alone, or before 2.8, has none, and +`dotskit init` writes it: it reads the solution, checks the reading and writes `.dotskit/manifest.json`, and +nothing else, after a yes. + +What it reads: + +- the version of the template from `DotNetSolutionKitVersion` in `Directory.Build.props`, there since 2.7.0; +- the names from `src/services/*.All.sln`; where a dot can belong to either name (`Acme.Corp.Shop`), from the + image names of `deploy/build-images.sh`, or from `-N` and `-P` given to the command; +- the database, the deployment, the test framework and the agent by the files only one choice generates; +- the flags of `Common` and the root by the files each flag adds; +- each service under `src/services` with its own API project: its flags by the sections of its + `appsettings.json` and the lines only a flag writes, its port by `launchSettings.json`. + +The check: `init` generates the solution again by what it read, with that version of the template, and +compares it with the folder - "412 of 420 files of the template reproduced; these differ: ...". A file that +differs is a change of the team or a wrong reading; the report lists it so a person can tell which before +anything relies on the manifest. Files the team added are not counted, and `All.sln` is compared by its +projects. The check installs the template of that version from nuget.org. + +| The folder | What `init` does | +|---|---| +| empty | writes nothing, and names `dotskit new -N -P -S ` | +| no `src/services/*.All.sln` | writes nothing: not a solution of DotNetSolutionKit | +| a solution of 2.7 or later, no manifest | reads it, checks it, shows the manifest and the report, writes it after a yes | +| a solution of 2.0-2.6, which does not say its version | writes nothing, and asks for `--template-version 2.x.y`; the check then shows how well that version reproduces the solution | +| generated from the template's sources (version `source`) | writes nothing, and asks for the release it came from with `--template-version` | +| newer than the tool | writes nothing, and names `dotnet tool update -g SawKing.DotsKit.Tool` | +| of an earlier major version than the tool | writes nothing: the tool describes solutions of its own major version | +| a service of another version than the solution | writes nothing, names the service and its version, and asks for the version to describe the solution by with `--template-version`; with it, the check shows what differs | +| with a manifest that lists every service | writes nothing: "the manifest is current: 2.8.0, services Orders, Billing" | +| with a manifest, and a service under `src/services` it does not list | adds the service, read from its files, after the same check | +| services, but no `Common` | writes the manifest without the check and says so: the database and the flags read from `Common` are the template's defaults; `dotskit new` takes `Common` for removed by the team and does not add it back | +| uncommitted changes in git | runs: it writes only `.dotskit/` | + ## Options | Option | What it does | @@ -88,6 +128,7 @@ gives one of them another value is refused. | `--force` | resolves a conflict with the template's lines; the team's lines stay in git history | | `--no-build` | does not build the solution after writing | | `--allow-dirty` | runs on a working tree with uncommitted changes | +| `--template-version 2.x.y` | `init`: the version the solution was made by, where it does not say it | ## Exit codes @@ -99,8 +140,6 @@ gives one of them another value is refused. ## Limits of this version -- `dotskit new` works only in a solution made by `dotskit`. A solution made with the template alone gets - its manifest from `dotskit init`, which comes in a later version. -- The solution and the tool must be of the same version. Upgrading a solution to a new version of the - template is `dotskit upgrade`, which comes in a later version; until then use the `dotskit` of the - solution's version: `dotnet tool update -g SawKing.DotsKit.Tool --version `. +- `dotskit new` works in a solution with a manifest: made by `dotskit`, or described by `dotskit init`. +- `dotskit new` needs a manifest of the tool's version: a solution of an earlier version is brought to it by + `dotskit upgrade` first. diff --git a/docs/getting-started/dotskit.ru.md b/docs/getting-started/dotskit.ru.md index 5c957d6..c8e9b9b 100644 --- a/docs/getting-started/dotskit.ru.md +++ b/docs/getting-started/dotskit.ru.md @@ -29,6 +29,9 @@ dotskit new -S Billing --Storage true # в решении: флаг для существующего сервиса dotskit new -S Orders --MongoDB true + +# в решении, созданном без dotskit: описать его в манифесте +dotskit init ``` `dotskit new` по папке решает, создать решение или добавить в него: ищет `src/services/*.All.sln` в папке @@ -79,6 +82,43 @@ dotskit new -S Orders --MongoDB true задаёт первая команда. Следующие команды берут их из манифеста; команда с другим значением одного из них отклоняется. +## Решение, созданное без dotskit + +`dotskit new` работает по манифесту. У решения, созданного одним шаблоном или до 2.8, манифеста нет, и его +пишет `dotskit init`: читает решение, проверяет прочитанное и после «да» записывает `.dotskit/manifest.json`, +больше ничего. + +Что он читает: + +- версию шаблона из `DotNetSolutionKitVersion` в `Directory.Build.props`, она там с 2.7.0; +- имена из `src/services/*.All.sln`; где точка может относиться к любому из имён (`Acme.Corp.Shop`), - из + имён образов в `deploy/build-images.sh` или из `-N` и `-P`, переданных команде; +- базу, развёртывание, тестовый фреймворк и агента по файлам, которые даёт только один вариант; +- флаги `Common` и корня по файлам, которые добавляет каждый флаг; +- каждый сервис в `src/services` с собственным API-проектом: его флаги по разделам `appsettings.json` и + строкам, которые пишет только флаг, его порт по `launchSettings.json`. + +Проверка: `init` генерирует решение заново по прочитанному, той же версией шаблона, и сравнивает с папкой - +«412 of 420 files of the template reproduced; these differ: ...». Отличающийся файл - это правка команды или +ошибка чтения; отчёт перечисляет такие файлы, чтобы человек разобрался до того, как на манифест что-то +опирается. Файлы, добавленные командой, не считаются, `All.sln` сравнивается по проектам. Шаблон этой версии +проверка ставит с nuget.org. + +| Папка | Что делает `init` | +|---|---| +| пустая | ничего не пишет и называет `dotskit new -N -P -S ` | +| нет `src/services/*.All.sln` | ничего не пишет: это не решение DotNetSolutionKit | +| решение 2.7 или новее, без манифеста | читает, проверяет, показывает манифест и отчёт, пишет после «да» | +| решение 2.0-2.6, которое не называет свою версию | ничего не пишет и просит `--template-version 2.x.y`; проверка затем показывает, насколько эта версия воспроизводит решение | +| сгенерировано из исходников шаблона (версия `source`) | ничего не пишет и просит выпуск, из которого оно взято, через `--template-version` | +| новее инструмента | ничего не пишет и называет `dotnet tool update -g SawKing.DotsKit.Tool` | +| более ранней мажорной версии, чем инструмент | ничего не пишет: инструмент описывает решения своей мажорной версии | +| сервис другой версии, чем решение | ничего не пишет, называет сервис и его версию и просит версию, которой описать решение, через `--template-version`; с ней проверка покажет, что отличается | +| с манифестом, в котором есть все сервисы | ничего не пишет: «the manifest is current: 2.8.0, services Orders, Billing» | +| с манифестом и сервисом в `src/services`, которого в нём нет | добавляет сервис, прочитанный по его файлам, после той же проверки | +| сервисы есть, а `Common` нет | пишет манифест без проверки и сообщает об этом: база и флаги, которые читаются из `Common`, берутся по умолчанию шаблона; `dotskit new` считает `Common` удалённым командой и не возвращает его | +| незакоммиченные изменения в git | работает: пишет только `.dotskit/` | + ## Опции | Опция | Что делает | @@ -87,6 +127,7 @@ dotskit new -S Orders --MongoDB true | `--force` | решает конфликт строками шаблона; строки команды остаются в истории git | | `--no-build` | не собирает решение после записи | | `--allow-dirty` | работает при незакоммиченных изменениях | +| `--template-version 2.x.y` | `init`: версия, которой создано решение, если оно её не называет | ## Коды выхода @@ -98,8 +139,6 @@ dotskit new -S Orders --MongoDB true ## Ограничения этой версии -- `dotskit new` работает только в решении, созданном `dotskit`. Решению, созданному одним шаблоном, - манифест даст `dotskit init`, он появится в следующей версии. -- Решение и инструмент должны быть одной версии. Обновление решения до новой версии шаблона - это - `dotskit upgrade`, он появится в следующей версии; до тех пор используйте `dotskit` версии решения: - `dotnet tool update -g SawKing.DotsKit.Tool --version <версия>`. +- `dotskit new` работает в решении с манифестом: созданном `dotskit` или описанном `dotskit init`. +- `dotskit new` нужен манифест версии инструмента: решение более ранней версии сначала приводит к ней + `dotskit upgrade`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 60aba4f..9dbd8e5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -38,7 +38,7 @@ gantt a service generated into a solution adds itself to All.sln, on any OS; no script to run :done, s26, after s25, 500ms dotskit new - a service or a flag added to a solution, the team's changes kept by a three-way merge :done, s27, after s26, 2000ms email and MongoDB in projects of their own - a service carries only its flags' packages (#98) :done, s28, after s25, 800ms - dotskit init - a manifest for a solution made without the tool, checked against it (#99) :s29, after s27, 1500ms + dotskit init - a manifest for a solution made without the tool, checked against it (#99) :done, s29, after s27, 1500ms dotskit upgrade - to the tool's version, one major at a time, the team's changes kept (#100) :s30, after s29, 3000ms section Next diff --git a/docs/roadmap.ru.md b/docs/roadmap.ru.md index 524211e..bfd48c3 100644 --- a/docs/roadmap.ru.md +++ b/docs/roadmap.ru.md @@ -38,7 +38,7 @@ gantt a service generated into a solution adds itself to All.sln, on any OS; no script to run :done, s26, after s25, 500ms dotskit new - a service or a flag added to a solution, the team's changes kept by a three-way merge :done, s27, after s26, 2000ms email and MongoDB in projects of their own - a service carries only its flags' packages (#98) :done, s28, after s25, 800ms - dotskit init - a manifest for a solution made without the tool, checked against it (#99) :s29, after s27, 1500ms + dotskit init - a manifest for a solution made without the tool, checked against it (#99) :done, s29, after s27, 1500ms dotskit upgrade - to the tool's version, one major at a time, the team's changes kept (#100) :s30, after s29, 3000ms section Next diff --git a/tool/DotsKit.Tests/InitCommandTests.cs b/tool/DotsKit.Tests/InitCommandTests.cs new file mode 100644 index 0000000..938c01e --- /dev/null +++ b/tool/DotsKit.Tests/InitCommandTests.cs @@ -0,0 +1,292 @@ +using DotsKit.Commands; +using DotsKit.Generation; +using DotsKit.Io; +using DotsKit.Solutions; + +namespace DotsKit.Tests; + +/// +/// Every state of a folder dotskit init meets, row by row of its table: a solution on disk, the generation that +/// checks the reading faked by a copy of the solution itself, so no template runs. +/// +[TestFixture] +[Parallelizable(ParallelScope.All)] +internal sealed class InitCommandTests +{ + private const string ToolVersion = "2.8.0"; + private const string Orders = "Acme.Shop.Orders"; + + private sealed class Fakes + { + public bool Answer { get; init; } = true; + public bool OnSources { get; init; } + public int Staged { get; private set; } + public List Lines { get; } = []; + + public InitCommand Command() => new(new Staging(this), new Projects(), new Terminal(this), ToolVersion, OnSources); + + /// What the template "gives" is the solution as it is, so the check reproduces every file. + private sealed class Staging(Fakes fakes) : IStaging + { + public Task BuildAsync(Manifest manifest, CancellationToken ct) + { + fakes.Staged++; + var staged = Folders.NewTemp("init-staged"); + Folders.Copy(fakes.Root!, staged); + return Task.FromResult(staged); + } + } + + private sealed class Projects : ISolutionProjects + { + public Task> ListAsync(string solutionFile, CancellationToken ct) => Task.FromResult(new HashSet()); + public Task AddMissingAsync(string root, CancellationToken ct) => Task.CompletedTask; + public Task RemoveMissingAsync(string root, CancellationToken ct) => Task.CompletedTask; + public Task AddAsync(string solutionFile, string listedProject, CancellationToken ct) => Task.CompletedTask; + public Task RemoveAsync(string solutionFile, string listedProject, CancellationToken ct) => Task.CompletedTask; + } + + private sealed class Terminal(Fakes fakes) : ITerminal + { + public void Info(string line) => fakes.Lines.Add(line); + + public void Error(string line) => fakes.Lines.Add(line); + + public bool Confirm(string question) => fakes.Answer; + } + + public string? Root { get; set; } + + public Task RunAsync(string root, params string[] args) + { + Root = root; + return Command().RunAsync(Options.Parse(args), root, CancellationToken.None); + } + } + + /// A solution of the template on disk: All.sln, Common, the version, and the service Orders. + private static string Solution(string? version = "2.8.0", bool common = true) + { + var root = Folders.NewTemp("init-command"); + Write(root, "src/services/Acme.Shop.All.sln", ""); + Service(root, Orders, 5000); + if (common) + Write(root, "src/common/Acme.Shop.Common/Acme.Shop.Common.csproj", ""); + if (version is not null) + Write(root, "Directory.Build.props", $"{version}"); + return root; + } + + private static void Service(string root, string folder, int port, string? version = null) + { + var api = $"src/services/{folder}/{folder}.API"; + Write(root, $"{api}/{folder}.API.csproj", version is null ? "" : $"{version}"); + Write(root, $"{api}/Properties/launchSettings.json", $"{{ \"applicationUrl\": \"http://localhost:{port}\" }}"); + Write(root, $"{api}/appsettings.json", "{ \"HangfireSettings\": {} }"); + } + + private static void Write(string root, string path, string text) + { + var full = Path.Combine(root, path); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, text); + } + + private static string ManifestOf(string root) => File.ReadAllText(Path.Combine(root, Layout.ManifestPath)); + + private static bool HasManifest(string root) => File.Exists(Path.Combine(root, Layout.ManifestPath)); + + [Test] + public async Task AnEmptyFolder_WritesNothing_AndNamesDotskitNew() + { + var folder = Folders.NewTemp("init-empty"); + + var refused = await Should.ThrowAsync(() => new Fakes().RunAsync(folder)); + + refused.Message.ShouldContain("dotskit new -N -P -S "); + } + + [Test] + public async Task AFolderWithoutAllSln_IsNotASolution() + { + var folder = Folders.NewTemp("init-other"); + Write(folder, "README.md", "not a solution"); + + (await Should.ThrowAsync(() => new Fakes().RunAsync(folder))).Message.ShouldContain("Not a solution of DotNetSolutionKit"); + } + + [Test] + public async Task ASolutionOf28_IsReadCheckedAndWritten_AfterAYes() + { + var root = Solution(); + var fakes = new Fakes(); + + (await fakes.RunAsync(root)).ShouldBe(ExitCode.Done); + + fakes.Staged.ShouldBe(1); + fakes.Lines.ShouldContain(l => l.Contains("files of the template reproduced.")); + Manifest.Parse(ManifestOf(root)).FindService("Orders")!.Port.ShouldBe(5000); + } + + [Test] + public async Task NoAnswer_WritesNothing() + { + var root = Solution(); + + (await new Fakes { Answer = false }.RunAsync(root)).ShouldBe(ExitCode.Failed); + + HasManifest(root).ShouldBeFalse(); + } + + [Test] + public async Task ASolutionThatDoesNotSayItsVersion_AsksForIt() + { + var root = Solution(version: null); + + (await Should.ThrowAsync(() => new Fakes().RunAsync(root))).Message.ShouldContain("--template-version 2.x.y"); + HasManifest(root).ShouldBeFalse(); + } + + [Test] + public async Task TheVersionTheCommandNames_IsTheManifests() + { + var root = Solution(version: null); + + (await new Fakes().RunAsync(root, "--template-version", "2.6.2")).ShouldBe(ExitCode.Done); + + Manifest.Parse(ManifestOf(root)).Template.ShouldBe("2.6.2"); + } + + [Test] + public async Task ASolutionOfTheTemplatesSources_AsksForTheRelease_UnlessTheToolRunsOnThem() + { + (await Should.ThrowAsync(() => new Fakes().RunAsync(Solution("source")))).Message.ShouldContain("--template-version"); + (await new Fakes { OnSources = true }.RunAsync(Solution("source"))).ShouldBe(ExitCode.Done); + } + + [Test] + public async Task ASolutionNewerThanTheTool_NamesTheUpdate() + { + var refused = await Should.ThrowAsync(() => new Fakes().RunAsync(Solution("2.9.0"))); + + refused.Message.ShouldContain("dotnet tool update -g SawKing.DotsKit.Tool"); + } + + [Test] + public async Task ASolutionOfAnEarlierMajor_IsNotDescribed() + { + (await Should.ThrowAsync(() => new Fakes().RunAsync(Solution("1.4.0")))).Message.ShouldContain("2.x"); + } + + [Test] + public async Task AManifestThatListsEveryService_IsCurrent_AndNothingIsGenerated() + { + var root = Solution(); + await new Fakes().RunAsync(root); + var fakes = new Fakes(); + + (await fakes.RunAsync(root)).ShouldBe(ExitCode.Done); + + fakes.Staged.ShouldBe(0); + fakes.Lines.ShouldContain("The manifest is current: 2.8.0, services Orders."); + } + + [Test] + public async Task AServiceTheManifestDoesNotList_IsAdded() + { + var root = Solution(); + await new Fakes().RunAsync(root); + Service(root, "Acme.Shop.Billing", 5001); + + (await new Fakes().RunAsync(root)).ShouldBe(ExitCode.Done); + + var manifest = Manifest.Parse(ManifestOf(root)); + manifest.ServiceArgs.Select(s => s.Service).ShouldBe(["Orders", "Billing"]); + manifest.FindService("Billing")!.Port.ShouldBe(5001); + } + + [Test] + public async Task ServicesWithoutCommon_GetTheirManifest_AndNoCheck() + { + var root = Solution(common: false); + var fakes = new Fakes(); + + (await fakes.RunAsync(root)).ShouldBe(ExitCode.Done); + + fakes.Staged.ShouldBe(0); + fakes.Lines.ShouldContain(l => l.StartsWith("No Common: the solution's database and flags", StringComparison.Ordinal)); + HasManifest(root).ShouldBeTrue(); + } + + [Test] + public async Task AServiceOfAnotherVersion_IsNamed_AndNothingWritten() + { + var root = Solution("2.8.0"); + Service(root, "Acme.Shop.Billing", 5001, version: "2.7.0"); + + var refused = await Should.ThrowAsync(() => new Fakes().RunAsync(root)); + + refused.Message.ShouldContain("Billing is 2.7.0"); + refused.Message.ShouldContain("--template-version 2.8.0"); + HasManifest(root).ShouldBeFalse(); + } + + [Test] + public async Task AServiceOfAnotherVersion_IsDescribedByTheVersionTheCommandNames() + { + var root = Solution("2.8.0"); + Service(root, "Acme.Shop.Billing", 5001, version: "2.8.1"); + + (await new Fakes().RunAsync(root, "--template-version", "2.8.0")).ShouldBe(ExitCode.Done); + + Manifest.Parse(ManifestOf(root)).Template.ShouldBe("2.8.0"); + } + + [Test] + public async Task AnUnlistedServiceOfAnotherVersion_IsNamed_AsWhenTheSolutionIsDescribed() + { + var root = Solution(); + await new Fakes().RunAsync(root); + Service(root, "Acme.Shop.Billing", 5001, version: "2.8.1"); + + (await Should.ThrowAsync(() => new Fakes().RunAsync(root))).Message.ShouldContain("Billing is 2.8.1"); + } + + [Test] + public async Task AVersionOtherThanTheManifests_IsRefused_WhenTheManifestGetsAService() + { + var root = Solution(); + await new Fakes().RunAsync(root); + Service(root, "Acme.Shop.Billing", 5001); + + (await Should.ThrowAsync(() => new Fakes().RunAsync(root, "--template-version", "2.7.0"))).Message.ShouldContain("The manifest is 2.8.0"); + } + + [Test] + public async Task AParameterOtherThanTheNames_IsRefused_SinceInitReadsThem() + { + var refused = await Should.ThrowAsync(() => new Fakes().RunAsync(Solution(), "--Storage", "true")); + + refused.Message.ShouldContain("--Storage true"); + } + + [Test] + public async Task AnEmptyFolderInsideASolution_FindsTheSolution() + { + var root = Solution(); + var inside = Path.Combine(root, "docs"); + Directory.CreateDirectory(inside); + var fakes = new Fakes { Root = root }; + + (await fakes.Command().RunAsync(Options.Parse([]), inside, CancellationToken.None)).ShouldBe(ExitCode.Done); + + HasManifest(root).ShouldBeTrue(); + } + + [Test] + public void TheTemplateVersionOption_BelongsToInit() + { + Should.Throw(() => Options.Parse("new", ["-S", "Billing", "--template-version", "2.7.0"])); + Options.Parse("init", ["--template-version", "2.7.0"]).TemplateVersion.ShouldBe("2.7.0"); + } +} diff --git a/tool/DotsKit.Tests/NewCommandTests.cs b/tool/DotsKit.Tests/NewCommandTests.cs index bc2442e..5f48383 100644 --- a/tool/DotsKit.Tests/NewCommandTests.cs +++ b/tool/DotsKit.Tests/NewCommandTests.cs @@ -103,10 +103,20 @@ public async Task Should_Refuse_When_TheSolutionIsOfAnotherVersion() var refused = await Should.ThrowAsync(() => fakes.Command().RunAsync(Args("-S", "Billing"), Solution("2.7.0"), CancellationToken.None)); - refused.Message.ShouldContain("--version 2.7.0"); + refused.Message.ShouldContain("dotskit upgrade"); fakes.Planned.ShouldBe(0); } + [Test] + public async Task Should_NameTheToolUpdate_When_TheSolutionIsNewerThanTheTool() + { + var fakes = new Fakes(); + + var refused = await Should.ThrowAsync(() => fakes.Command().RunAsync(Args("-S", "Billing"), Solution("2.9.0"), CancellationToken.None)); + + refused.Message.ShouldContain("dotnet tool update -g SawKing.DotsKit.Tool"); + } + [Test] public async Task Should_Refuse_When_ACommandGivesTheSolutionAnotherDatabase() { diff --git a/tool/DotsKit.Tests/Scenarios/InitScenarios.cs b/tool/DotsKit.Tests/Scenarios/InitScenarios.cs new file mode 100644 index 0000000..a62c7bb --- /dev/null +++ b/tool/DotsKit.Tests/Scenarios/InitScenarios.cs @@ -0,0 +1,104 @@ +namespace DotsKit.Tests.Scenarios; + +/// +/// A solution made with the template alone, as before dotskit: init describes it, the template generates it +/// again by that description and reproduces every file, and dotskit then adds to it. +/// +[TestFixture] +[Category(Sandbox.Category)] +[Parallelizable(ParallelScope.All)] +internal sealed class InitScenarios +{ + private const string Reproduced = " files of the template reproduced."; + + /// The solution with its first service, then each service generated into it, by the template alone. + private static async Task MadeByTheTemplateAsync(string[] solution, params string[][] services) + { + var made = await Sandbox.EmptyAsync(); + await GenerateAsync(made, [.. solution, "--Solution"]); + foreach (var service in services) + await GenerateAsync(made, [.. solution.Where((_, i) => i < 4), .. service]); + await made.CommitAsync("made by the template alone"); + return made; + } + + private static async Task GenerateAsync(Sandbox solution, string[] args) + { + var result = await solution.DotnetNewAsync([.. args, "--allow-scripts", "yes"]); + result.Code.ShouldBe(0, result.Output + result.Error); + } + + [Test] + public async Task ASolutionWithFlagsOnItsServices_IsReproducedWhole_AndDotskitAddsToIt() + { + var solution = await MadeByTheTemplateAsync( + ["-N", "Acme", "-P", "Shop", "-S", "Orders", "--Storage", "true", "--MongoDB", "true", "--Notify", "email", + "--Messaging", "outbox", "--Audit", "true", "--DiffApi", "true", "--FeatureFlags", "true", "--ClickHouse", "true"], + ["-S", "Billing", "--Storage", "true", "--Messaging", "outbox", "--Notify", "email", "--Audit", "true"], + ["-S", "Stock", "--Hangfire", "false", "--Messaging", "direct", "--ClickHouse", "true", "--FeatureFlags", "true"]); + + var init = await solution.DotskitAsync("init", "--yes"); + + init.Code.ShouldBe(0, init.Output + init.Error); + init.Output.ShouldContain(Reproduced); + var manifest = Manifest.Parse(solution.Read(Layout.ManifestPath)); + manifest.FindService("Stock")!.IsOn("Hangfire").ShouldBeFalse(); + manifest.FindService("Billing")!["Messaging"].ShouldBe("outbox"); + await solution.CommitAsync("described"); + + var added = await solution.DotskitAsync("new", "-S", "Catalog", "--MongoDB", "true", "--yes"); + added.Code.ShouldBe(0, added.Output + added.Error); + } + + [Test] + public async Task ASolutionOfOtherChoices_AndAGateway_IsReproducedWhole() + { + var solution = await MadeByTheTemplateAsync( + ["-N", "Acme", "-P", "Shop", "-S", "Orders", "--Database", "mssql", "--Deploy", "k8s", "--TestFramework", "xunit", + "--Agent", "opencode", "--GitHubCiCd", "true", "--Vault", "true", "--Infisical", "true", "--HierarchyRules", "true"], + ["-S", "Gate", "--ApiGateway", "true", "--Database", "mssql", "--Deploy", "k8s", "--TestFramework", "xunit", "--Agent", "opencode"], + ["-S", "Billing", "--Database", "mssql", "--Deploy", "k8s", "--TestFramework", "xunit", "--Agent", "opencode", "--Vault", "true"]); + + var init = await solution.DotskitAsync("init", "--yes"); + + init.Code.ShouldBe(0, init.Output + init.Error); + init.Output.ShouldContain(Reproduced); + Manifest.Parse(solution.Read(Layout.ManifestPath)).FindService("Gate")!.IsOn("ApiGateway").ShouldBeTrue(); + } + + [Test] + public async Task ASolutionOf27FromNuGet_IsReproducedWhole_ByThatVersion() + { + var solution = await Sandbox.EmptyAsync(); + var hive = Io.Folders.NewTemp("hive-27"); + (await solution.DotnetAsync("new", "install", "SawKing.DotNetSolutionKit::2.7.0", "--debug:custom-hive", hive)).Code.ShouldBe(0); + foreach (var args in new[] { new[] { "-S", "Orders", "-M", "false" }, ["-S", "Billing"] }) + { + var made = await solution.DotnetAsync(["new", "DotNetSolutionKit", "-N", "Acme", "-P", "Shop", .. args, "--Storage", "true", "--Messaging", "outbox", "--debug:custom-hive", hive]); + made.Code.ShouldBe(0, made.Output + made.Error); + } + // 2.7 left a service out of All.sln until manual-add-projects.sh, which adds it as this does. + await new Solutions.SolutionProjects(new Io.ProcessRunner()).AddMissingAsync(solution.Root, CancellationToken.None); + await solution.CommitAsync("made by the template 2.7.0"); + + var init = await solution.DotskitAsync("init", "--yes"); + + init.Code.ShouldBe(0, init.Output + init.Error); + init.Output.ShouldContain(Reproduced); + Manifest.Parse(solution.Read(Layout.ManifestPath)).Template.ShouldBe("2.7.0"); + } + + [Test] + public async Task AServiceTheTemplateAddedToASolutionOfDotskit_IsAddedToItsManifest() + { + var solution = await Sandbox.SolutionAsync(); + await GenerateAsync(solution, ["-N", "Acme", "-P", "Shop", "-S", "Stock", "--HttpPort", "5100"]); + await solution.CommitAsync("a service by the template alone"); + + var init = await solution.DotskitAsync("init", "--yes"); + + init.Code.ShouldBe(0, init.Output + init.Error); + init.Output.ShouldContain(Reproduced); + Manifest.Parse(solution.Read(Layout.ManifestPath)).FindService("Stock")!.Port.ShouldBe(5100); + } +} diff --git a/tool/DotsKit.Tests/Scenarios/Sandbox.cs b/tool/DotsKit.Tests/Scenarios/Sandbox.cs index 79f0d9c..35ea05b 100644 --- a/tool/DotsKit.Tests/Scenarios/Sandbox.cs +++ b/tool/DotsKit.Tests/Scenarios/Sandbox.cs @@ -32,6 +32,16 @@ public static async Task SolutionAsync() return sandbox; } + /// An empty folder under git, for a solution the test makes itself. + public static async Task EmptyAsync() + { + var root = Path.Combine(Folders.NewTemp("scenario"), "solution"); + Directory.CreateDirectory(root); + var sandbox = new Sandbox(root); + await sandbox.GitAsync("init", "-q"); + return sandbox; + } + /// The folder with template/.template.config, found from where the tests run. public static string TemplateSources { diff --git a/tool/DotsKit.Tests/SolutionReaderTests.cs b/tool/DotsKit.Tests/SolutionReaderTests.cs new file mode 100644 index 0000000..e14b252 --- /dev/null +++ b/tool/DotsKit.Tests/SolutionReaderTests.cs @@ -0,0 +1,205 @@ +using DotsKit.Reading; + +namespace DotsKit.Tests; + +/// How a solution made without dotskit is read into a manifest: files in memory, no template. +[TestFixture] +[Parallelizable(ParallelScope.All)] +internal sealed class SolutionReaderTests +{ + private const string Prefix = "Acme.Shop"; + private const string Orders = "Acme.Shop.Orders"; + + /// A solution as a set of files: a path with null text is a folder or a file whose text is not read. + private sealed class Files : ISolutionFiles + { + private readonly Dictionary _files = new(StringComparer.Ordinal); + + public Files With(string path, string? text = null) + { + _files[path] = text; + return this; + } + + /// A service the template generated: its API project, its port, its appsettings.json sections. + public Files WithService(string folder, int port, params string[] sections) => + With($"src/services/{folder}/{folder}.API/{folder}.API.csproj", "") + .With($"src/services/{folder}/{folder}.API/Properties/launchSettings.json", $"\"applicationUrl\": \"http://localhost:{port}\"") + .With($"src/services/{folder}/{folder}.API/appsettings.json", "{" + string.Join(",", sections.Select(s => $"\"{s}\": {{}}")) + "}"); + + public bool Exists(string path) => + path.EndsWith('*') + ? _files.Keys.Any(k => k.StartsWith(path[..^1], StringComparison.Ordinal)) + : _files.Keys.Any(k => k == path || k.StartsWith(path + "/", StringComparison.Ordinal)); + + public string? Read(string path) => _files.GetValueOrDefault(path); + + public IReadOnlyList Folders(string path) => + [.. _files.Keys.Where(k => k.StartsWith(path + "/", StringComparison.Ordinal)) + .Select(k => k[(path.Length + 1)..].Split('/')) + .Where(parts => parts.Length > 1) + .Select(parts => parts[0]) + .Distinct() + .Order(StringComparer.Ordinal)]; + } + + private static Files Solution() => new Files().WithService(Orders, 5000, "HangfireSettings"); + + private static Manifest Read(Files files, params string[] given) => + SolutionReader.Read(files, Prefix, "2.8.0", TemplateArgs.Parse(given)); + + [Test] + public void ADefaultSolution_IsReadWithTheNamesAndTheServiceAlone() + { + var manifest = Read(Solution().With("deploy/compose/.env.example", "HANGFIRE_DASHBOARD_PASSWORD=").With(".claude/rules.md")); + + // Hangfire is on by default, and a service with it widens the solution, as dotskit new does. + manifest.Solution.ShouldBe(["--NamespaceRoot", "Acme", "--ProductName", "Shop", "--Hangfire", "true"]); + manifest.Services.ShouldBe([["--ServiceNameOrCustom", "Orders", "--HttpPort", "5000"]]); + } + + [Test] + public void TheNamesOfADottedProduct_AreToldApartByTheImageNames() + { + var files = new Files().WithService("Acme.Corp.Retail.Shop.Orders", 5000, "HangfireSettings") + .With("deploy/build-images.sh", " name=\"retail_shop-$(echo \"$service\" | tr)\""); + + var manifest = SolutionReader.Read(files, "Acme.Corp.Retail.Shop", "2.8.0", TemplateArgs.Empty); + + manifest.SolutionArgs["NamespaceRoot"].ShouldBe("Acme.Corp"); + manifest.SolutionArgs["ProductName"].ShouldBe("Retail.Shop"); + } + + [Test] + public void AProductWithAnUnderscore_IsToldApartByTheWholeImageName() + { + var files = new Files().WithService("Acme.Corp.Retail_Shop.Orders", 5000, "HangfireSettings") + .With("deploy/build-images.sh", " name=\"retail_shop-$(echo \"$service\" | tr)\""); + + var manifest = SolutionReader.Read(files, "Acme.Corp.Retail_Shop", "2.8.0", TemplateArgs.Empty); + + manifest.SolutionArgs["NamespaceRoot"].ShouldBe("Acme.Corp"); + manifest.SolutionArgs["ProductName"].ShouldBe("Retail_Shop"); + } + + [Test] + public void TheFirstServiceOfA27Solution_IsTheOneItsScriptNames_NotOneItsNameStartsWith() + { + var files = Solution().WithService("Acme.Shop.OrdersArchive", 5001, "HangfireSettings") + .With("src/services/manual-add-projects.sh", " full_folder=$(basename \"$service_dir\") # Acme.Shop.OrdersArchive\n"); + + Read(files).ServiceArgs[0].Service.ShouldBe("OrdersArchive"); + } + + [Test] + public void NamesNothingTellsApart_AreAskedFor() + { + var files = new Files().WithService("Acme.Corp.Shop.Orders", 5000); + + Should.Throw(() => SolutionReader.Read(files, "Acme.Corp.Shop", "2.8.0", TemplateArgs.Empty)) + .Message.ShouldContain("-N -P "); + SolutionReader.Read(files, "Acme.Corp.Shop", "2.8.0", TemplateArgs.Parse(["-N", "Acme", "-P", "Corp.Shop"])) + .SolutionArgs["ProductName"].ShouldBe("Corp.Shop"); + } + + [Test] + public void NamesThatDoNotMakeThePrefix_AreRefused() + { + Should.Throw(() => Read(Solution(), "-N", "Acme", "-P", "Store")).Message.ShouldContain("Acme.Shop"); + } + + [Test] + public void AServiceFlag_IsReadFromItsSectionOfAppSettings_AndWidensTheSolution() + { + var files = Solution().WithService("Acme.Shop.Billing", 5001, "HangfireSettings", "S3", "MongoDB", "Email") + .With("src/common/Acme.Shop.Common.Infrastructure/Storage/DependencyInjection.cs") + .With("src/capabilities/Acme.Shop.Capabilities.Mongo/Mongo.csproj") + .With("src/capabilities/Acme.Shop.Capabilities.Notifications/Notifications.csproj"); + + var billing = Read(files).FindService("Billing")!; + + billing.IsOn("Storage").ShouldBeTrue(); + billing.IsOn("MongoDB").ShouldBeTrue(); + billing["Notify"].ShouldBe("email"); + Read(files).FindService("Orders")!.IsOn("MongoDB").ShouldBeFalse(); + Read(files).SolutionArgs.IsOn("MongoDB").ShouldBeTrue(); + } + + [Test] + public void TheBus_IsTheOutboxWhereTheServiceNeedsTheDatabaseForIt_AndDirectOtherwise() + { + var files = Solution() + .WithService("Acme.Shop.Billing", 5001, "HangfireSettings", "RabbitMq") + .With("src/services/Acme.Shop.Billing/Acme.Shop.Billing.API/Setup/ApplicationConfiguration.cs", "AddDependencyOverrides(busNeedsDatabase: true);") + .WithService("Acme.Shop.Stock", 5002, "HangfireSettings", "RabbitMq"); + + var manifest = Read(files); + + manifest.FindService("Billing")!["Messaging"].ShouldBe("outbox"); + manifest.FindService("Stock")!["Messaging"].ShouldBe("direct"); + manifest.FindService("Orders")!["Messaging"].ShouldBe("none"); + manifest.SolutionArgs["Messaging"].ShouldBe("outbox"); + } + + [Test] + public void AServiceWithoutHangfireSettings_HasNoJobs() + { + var files = new Files().WithService(Orders, 5000); + + Read(files).FindService("Orders")!.IsOn("Hangfire").ShouldBeFalse(); + } + + [Test] + public void AGateway_IsReadAsOne_WithItsPort() + { + var files = Solution().WithService("Acme.Shop.Gate", 5005) + .With("src/services/Acme.Shop.Gate/Acme.Shop.Gate.API/Setup/GatewayTransforms.cs"); + + var gate = Read(files).FindService("Gate")!; + + gate.IsOn("ApiGateway").ShouldBeTrue(); + gate.Port.ShouldBe(5005); + } + + [Test] + public void TheSolutionsChoices_AreReadFromWhatOnlyThatChoiceGenerates() + { + var files = Solution() + .With("src/common/Acme.Shop.Common.Infrastructure/Persistence/SqlServer/SqlServerErrors.cs") + .With("deploy/k8s/namespace.yaml") + .With(".opencode/rules.md") + .With("src/services/Acme.Shop.Orders/Acme.Shop.Orders.Tests/Acme.Shop.Orders.Tests.csproj", "") + .With("src/common/Acme.Shop.Common.Infrastructure/Configuration/Secrets/VaultOptions.cs") + .With(".github/workflows/ci.yml"); + + var solution = Read(files).SolutionArgs; + + solution["Database"].ShouldBe("mssql"); + solution["Deploy"].ShouldBe("k8s"); + solution["Agent"].ShouldBe("opencode"); + solution["TestFramework"].ShouldBe("xunit"); + solution.IsOn("Vault").ShouldBeTrue(); + solution.IsOn("Infisical").ShouldBeFalse(); + solution.IsOn("GitHubCiCd").ShouldBeTrue(); + } + + [Test] + public void AFolderOfTheTeam_WithoutAnApiProjectOfItsName_IsNotAService() + { + var files = Solution().With("src/services/Acme.Shop.Tools/Acme.Shop.Tools.Cli/Acme.Shop.Tools.Cli.csproj"); + + Read(files).Services.Count.ShouldBe(1); + } + + [Test] + public void TheVersions_AreReadFromWhereTheTemplateWritesThem() + { + var files = Solution() + .With("Directory.Build.props", "2.7.0") + .With($"src/services/{Orders}/{Orders}.API/{Orders}.API.csproj", "2.6.0"); + + SolutionReader.Version(files).ShouldBe("2.7.0"); + SolutionReader.ServiceVersion(files, Prefix, Orders).ShouldBe("2.6.0"); + SolutionReader.Version(new Files()).ShouldBeNull(); + } +} diff --git a/tool/DotsKit/Commands/InitCommand.cs b/tool/DotsKit/Commands/InitCommand.cs new file mode 100644 index 0000000..44710fd --- /dev/null +++ b/tool/DotsKit/Commands/InitCommand.cs @@ -0,0 +1,122 @@ +using DotsKit.Generation; +using DotsKit.Io; +using DotsKit.Reading; +using DotsKit.Solutions; + +namespace DotsKit.Commands; + +/// +/// dotskit init: describes a solution made without dotskit in its manifest. Reads the manifest from the +/// solution's files, generates the solution again by it with the version it names, shows how much of the +/// template's files that reproduces, and writes .dotskit/manifest.json - and nothing else - after a yes. +/// A solution with a manifest gets the services it does not list. +/// +/// The tool runs against the template's sources: a solution of version "source" is theirs. +internal sealed class InitCommand(IStaging staging, ISolutionProjects projects, ITerminal terminal, string toolVersion, bool onSources) +{ + public async Task RunAsync(Options options, string folder, CancellationToken ct) + { + var root = SolutionRoot.Find(folder) ?? throw new InvalidOperationException(SolutionRoot.IsEmpty(folder) + ? "No solution here: dotskit new -N -P -S makes one, with its manifest." + : $"Not a solution of DotNetSolutionKit: there is no {Layout.ServicesFolder}/{Layout.SolutionPattern}."); + var files = new DiskSolutionFiles(root); + var prefix = SolutionReader.Prefix(Path.GetFileName(Layout.SolutionFileOf(root))); + var given = Given(options); + var current = Manifest.Load(root); + var next = current is null ? Described(files, prefix, options, given) : WithUnlisted(current, files, prefix, options); + if (next is null) + return Current(current!); + if (!files.Exists(Layout.CommonFolder)) + return await WriteAsync(root, next, options, + "No Common: the solution's database and flags, which are read from it, are the template's defaults here, and there is nothing to check the reading against. dotskit new takes Common for removed by the team and does not add it back."); + var reproduction = await CheckAsync(root, next, ct); + foreach (var line in reproduction.Lines()) + terminal.Info(line); + return await WriteAsync(root, next, options, reproduction.IsWhole ? null : "A file that differs is a change of yours or a wrong reading: check them before you rely on the manifest."); + } + + /// The names -N and -P, the only arguments of the template init takes: everything else it reads. + private static TemplateArgs Given(Options options) + { + var given = TemplateArgs.Parse(options.TemplateArgs); + var other = given.Without("NamespaceRoot", "ProductName").ToArgs(); + return other.Count == 0 + ? given + : throw new ArgumentException($"dotskit init reads the solution's parameters from its files and takes only -N and -P; leave out {string.Join(' ', other)}."); + } + + /// The manifest of a solution without one, of the version it says or the command names. + private Manifest Described(ISolutionFiles files, string prefix, Options options, TemplateArgs given) + { + var version = options.TemplateVersion ?? SolutionReader.Version(files); + if (TemplateVersions.WhyNotDescribed(version, toolVersion, onSources) is { } why) + throw new InvalidOperationException(why); + RefuseServicesOfOtherVersions(files, prefix, SolutionReader.ServiceFolders(files, prefix), version!, options); + return SolutionReader.Read(files, prefix, version!, given); + } + + /// + /// A service generated by another version than the manifest's: the manifest keeps one version, and the check + /// generates the service by it. A person names the version to describe the solution by, and the check shows + /// what differs. + /// + private static void RefuseServicesOfOtherVersions(ISolutionFiles files, string prefix, IEnumerable services, string version, Options options) + { + if (options.TemplateVersion is not null) + return; + var others = services + .Select(f => (Name: SolutionReader.ServiceName(prefix, f), Version: SolutionReader.ServiceVersion(files, prefix, f))) + .Where(s => s.Version is not null && s.Version != version && s.Version != TemplateSource.SourcesVersion) + .ToList(); + if (others.Count > 0) + throw new InvalidOperationException( + $"The solution is {version}, and {string.Join(", ", others.Select(s => $"{s.Name} is {s.Version}"))}: the manifest keeps one version; " + + $"name the one to describe the solution by, dotskit init --template-version {version}, and the check shows what differs."); + } + + /// The manifest with the services of src/services it does not list; null when it lists them all. + private static Manifest? WithUnlisted(Manifest current, ISolutionFiles files, string prefix, Options options) + { + var unlisted = SolutionReader.ServiceFolders(files, prefix) + .Where(f => current.FindService(SolutionReader.ServiceName(prefix, f)) is null) + .ToList(); + if (options.TemplateVersion is { } named && named != current.Template) + throw new ArgumentException($"The manifest is {current.Template}: a service it does not list is described by that version, not {named}; dotskit upgrade moves the manifest to another."); + RefuseServicesOfOtherVersions(files, prefix, unlisted, current.Template, options); + return unlisted.Count == 0 ? null : unlisted.Aggregate(current, (m, f) => m.WithService(SolutionReader.Service(files, prefix, f))); + } + + private ExitCode Current(Manifest manifest) + { + terminal.Info($"The manifest is current: {manifest.Template}, services {string.Join(", ", manifest.ServiceArgs.Select(s => s.Service))}."); + return ExitCode.Done; + } + + /// The solution generated again by the manifest, compared with the folder. + private async Task CheckAsync(string root, Manifest manifest, CancellationToken ct) + { + terminal.Info($"Generating the solution again by the template {manifest.Template} to check the reading..."); + var staged = await staging.BuildAsync(manifest, ct); + return Reproduction.Of(root, staged, + await projects.ListAsync(Layout.SolutionFileOf(root), ct), + await projects.ListAsync(Layout.SolutionFileOf(staged), ct)); + } + + private async Task WriteAsync(string root, Manifest manifest, Options options, string? note) + { + terminal.Info($" manifest {Layout.ManifestPath}"); + terminal.Info(manifest.Serialize().TrimEnd()); + if (note is not null) + terminal.Info(note); + if (!options.Yes && !terminal.Confirm("Write the manifest?")) + { + terminal.Info("Nothing written: answer y to write, or pass --yes where no one can answer."); + return ExitCode.Failed; + } + var transaction = new FileTransaction(root); + transaction.Write(Layout.ManifestPath, System.Text.Encoding.UTF8.GetBytes(manifest.Serialize())); + await transaction.CommitAsync(() => Task.CompletedTask); + terminal.Info($"Written {Layout.ManifestPath}: dotskit new and dotskit upgrade work from it."); + return ExitCode.Done; + } +} diff --git a/tool/DotsKit/Commands/NewCommand.cs b/tool/DotsKit/Commands/NewCommand.cs index 93a8ff2..4d6011f 100644 --- a/tool/DotsKit/Commands/NewCommand.cs +++ b/tool/DotsKit/Commands/NewCommand.cs @@ -40,10 +40,12 @@ private async Task RefuseFilesOutsideGitAsync(string folder, Options options, Ca private async Task LoadCheckedAsync(string root, Options options, CancellationToken ct) { var manifest = Manifest.Load(root) - ?? throw new InvalidOperationException($"{Layout.ManifestPath} is missing: dotskit works with a solution it made; one made with the template alone gets described by dotskit init, which comes in a later version."); + ?? throw new InvalidOperationException($"{Layout.ManifestPath} is missing: describe the solution first, dotskit init."); var version = TemplateSource.Current(toolVersion); if (manifest.Template != version) - throw new InvalidOperationException($"The solution is of the template {manifest.Template}, dotskit is {version}: use the dotskit of the solution, dotnet tool update -g SawKing.DotsKit.Tool --version {manifest.Template}."); + throw new InvalidOperationException(TemplateVersions.IsNewer(manifest.Template, version) + ? $"The solution is of the template {manifest.Template}, newer than dotskit {version}: update the tool, dotnet tool update -g SawKing.DotsKit.Tool." + : $"The solution is of the template {manifest.Template}, dotskit is {version}: bring the solution to {version} first, dotskit upgrade."); if (!options.AllowDirty && !await git.IsCleanAsync(root, ct)) throw new InvalidOperationException("The git working tree has uncommitted changes: commit or stash them, so what dotskit writes can be seen and undone (--allow-dirty to go on)."); return manifest; diff --git a/tool/DotsKit/Generation/Template.cs b/tool/DotsKit/Generation/Template.cs index 6e7cc51..9c8c624 100644 --- a/tool/DotsKit/Generation/Template.cs +++ b/tool/DotsKit/Generation/Template.cs @@ -20,7 +20,11 @@ internal interface ITemplateSource /// The package comes from nuget.org by version; a folder of the template's sources stands in for it when the /// tool runs against the template being developed (). /// -internal sealed class DotnetNewTemplate(string package, IProcessRunner runner) : ITemplate +/// +/// The version has the post-action that adds a service to All.sln by a command, and takes --allow-scripts for it; +/// a version without one refuses the option (). +/// +internal sealed class DotnetNewTemplate(string package, bool runsScripts, IProcessRunner runner) : ITemplate { private const string ShortName = "DotNetSolutionKit"; @@ -31,11 +35,12 @@ public async Task GenerateAsync(TemplateArgs args, string root, CancellationToke { await InstallOnceAsync(ct); Directory.CreateDirectory(root); - // --allow-scripts yes: the post-action that adds a service to All.sln runs a command. - List command = ["new", ShortName, .. args.ToArgs(), "--output", root, "--allow-scripts", "yes", "--debug:custom-hive", _hive]; + List command = ["new", ShortName, .. args.ToArgs(), "--output", root, .. ScriptsAllowed(), "--debug:custom-hive", _hive]; await runner.RunCheckedAsync("dotnet", command, root, ct); } + private string[] ScriptsAllowed() => runsScripts ? ["--allow-scripts", "yes"] : []; + private async Task InstallOnceAsync(CancellationToken ct) { if (_installed) @@ -64,7 +69,7 @@ internal sealed class TemplateSource(IProcessRunner runner) : ITemplateSource public ITemplate Of(string version) { if (!_templates.TryGetValue(version, out var template)) - _templates[version] = template = new DotnetNewTemplate(PackageOf(version), runner); + _templates[version] = template = new DotnetNewTemplate(PackageOf(version), TemplateVersions.AddsServiceToSolution(version), runner); return template; } diff --git a/tool/DotsKit/Generation/TemplateVersions.cs b/tool/DotsKit/Generation/TemplateVersions.cs index cb4762b..f1261aa 100644 --- a/tool/DotsKit/Generation/TemplateVersions.cs +++ b/tool/DotsKit/Generation/TemplateVersions.cs @@ -27,6 +27,35 @@ internal static class TemplateVersions public static bool IsVersion(string version) => version == TemplateSource.SourcesVersion || Version.TryParse(version.Split('-')[0], out _); + /// + /// Why a solution of cannot be described by the tool of , + /// with what to do instead; null when it can. The tool run against the template's sources takes any version. + /// + /// The version the solution says, or the command names; null when there is none. + public static string? WhyNotDescribed(string? version, string toolVersion, bool onSources) + { + if (version is null) + return "The solution does not say its template version (it does since 2.7.0): name it, dotskit init --template-version 2.x.y."; + if (version == TemplateSource.SourcesVersion) + return onSources ? null : "The solution was made from the template's sources: name the release it came from, dotskit init --template-version 2.x.y."; + if (!IsVersion(version)) + return $"\"{version}\" is not a version of the template: name one, dotskit init --template-version 2.x.y."; + if (onSources) + return null; + var solution = Version.Parse(version.Split('-')[0]); + var tool = Version.Parse(toolVersion.Split('-')[0]); + if (solution > tool) + return $"The solution was made by the template {version}, dotskit is {toolVersion}: update the tool, dotnet tool update -g SawKing.DotsKit.Tool."; + return solution.Major < tool.Major + ? $"The solution was made by the template {version}; dotskit {toolVersion} describes solutions of {tool.Major}.x: bring it to {tool.Major}.0 by the template's upgrading notes first." + : null; + } + + /// Whether is newer than ; the template's sources are the newest. + public static bool IsNewer(string version, string than) => + version != than && (version == TemplateSource.SourcesVersion + || (than != TemplateSource.SourcesVersion && Version.Parse(version.Split('-')[0]) > Version.Parse(than.Split('-')[0]))); + /// The template's sources are always the newest; a pre-release (2.8.0-rc) is its release. private static bool IsAtLeast(string version, Version since) => version == TemplateSource.SourcesVersion || Version.Parse(version.Split('-')[0]) >= since; diff --git a/tool/DotsKit/Options.cs b/tool/DotsKit/Options.cs index 408cf23..e8f240a 100644 --- a/tool/DotsKit/Options.cs +++ b/tool/DotsKit/Options.cs @@ -9,35 +9,53 @@ internal sealed class Options dotskit new -N MyCompany -P MyProduct -S Orders in a folder with no solution: make one with this service dotskit new -S Billing --Storage true in a solution: add a service; it takes the solution's names dotskit new -S Orders --MongoDB true in a solution: add a flag to a service it has + dotskit init in a solution made without dotskit: describe it in its manifest Every command shows what it will change and asks before writing. --yes write without asking (CI) --force in a conflict take the template's side; your lines stay in git history --no-build do not build the solution after writing --allow-dirty run on a working tree with uncommitted changes + --template-version 2.x.y init: the version a solution was made by, where it does not say """; private const string YesOption = "--yes"; private const string ForceOption = "--force"; private const string NoBuildOption = "--no-build"; private const string AllowDirtyOption = "--allow-dirty"; + private const string TemplateVersionOption = "--template-version"; public bool Yes { get; private init; } public bool Force { get; private init; } public bool NoBuild { get; private init; } public bool AllowDirty { get; private init; } + public string? TemplateVersion { get; private init; } public List TemplateArgs { get; private init; } = []; + /// The options of , refusing one that belongs to another command. + public static Options Parse(string command, IReadOnlyList args) + { + var options = Parse(args); + return options.TemplateVersion is null || command == "init" + ? options + : throw new ArgumentException($"{TemplateVersionOption} is an option of dotskit init."); + } + public static Options Parse(IReadOnlyList args) { string[] flags = [YesOption, ForceOption, NoBuildOption, AllowDirtyOption]; + var at = args.ToList().FindIndex(a => Is(a, TemplateVersionOption)); + if (at >= 0 && at + 1 >= args.Count) + throw new ArgumentException($"{TemplateVersionOption} needs a version: {TemplateVersionOption} 2.7.0."); + var rest = at < 0 ? args : [.. args.Take(at), .. args.Skip(at + 2)]; return new Options { - Yes = args.Any(a => Is(a, YesOption)), - Force = args.Any(a => Is(a, ForceOption)), - NoBuild = args.Any(a => Is(a, NoBuildOption)), - AllowDirty = args.Any(a => Is(a, AllowDirtyOption)), - TemplateArgs = [.. args.Where(a => !flags.Any(f => Is(a, f)))], + Yes = rest.Any(a => Is(a, YesOption)), + Force = rest.Any(a => Is(a, ForceOption)), + NoBuild = rest.Any(a => Is(a, NoBuildOption)), + AllowDirty = rest.Any(a => Is(a, AllowDirtyOption)), + TemplateVersion = at < 0 ? null : args[at + 1], + TemplateArgs = [.. rest.Where(a => !flags.Any(f => Is(a, f)))], }; } diff --git a/tool/DotsKit/Program.cs b/tool/DotsKit/Program.cs index 2ed93a7..df4c812 100644 --- a/tool/DotsKit/Program.cs +++ b/tool/DotsKit/Program.cs @@ -21,7 +21,8 @@ var runner = new ProcessRunner(); var projects = new SolutionProjects(runner); -var planner = new Planner(new Staging(new TemplateSource(runner), projects), new ThreeWayMerge(new GitMergeFile(runner)), projects); +var staging = new Staging(new TemplateSource(runner), projects); +var planner = new Planner(staging, new ThreeWayMerge(new GitMergeFile(runner)), projects); var report = new PlanReport(terminal); var writer = new PlanWriter(projects); var git = new Git(runner); @@ -33,11 +34,12 @@ Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; try { - var options = Options.Parse(args[1..]); + var options = Options.Parse(args[0], args[1..]); var folder = Folders.RealPath(Directory.GetCurrentDirectory()); var code = args[0] switch { "new" => await new NewCommand(planner, report, writer, git, builder, terminal, ToolVersion.Current).RunAsync(options, folder, cts.Token), + "init" => await new InitCommand(staging, projects, terminal, ToolVersion.Current, TemplateSource.SourcesFolder is not null).RunAsync(options, folder, cts.Token), _ => throw new ArgumentException($"Unknown command {args[0]}.\n{Options.Usage}"), }; return (int)code; diff --git a/tool/DotsKit/Reading/Markers.cs b/tool/DotsKit/Reading/Markers.cs new file mode 100644 index 0000000..32af7dd --- /dev/null +++ b/tool/DotsKit/Reading/Markers.cs @@ -0,0 +1,92 @@ +namespace DotsKit.Reading; + +/// +/// What each parameter of the template leaves in a solution, so a solution made without dotskit can be described: +/// a file or a folder only one value generates, or a section of a service's appsettings.json. Paths are relative +/// to the solution's root, {P} standing for its prefix (NamespaceRoot.ProductName) and {S} for a +/// service's folder. They hold for the template since 2.7, the first version that names itself in a solution; +/// whatever a marker misses, the check that generates the solution again shows. +/// +internal static class Markers +{ + public const string Prefix = "{P}"; + public const string Service = "{S}"; + + /// The flags of the solution by what Common, the capabilities and the root have only with them. + public static readonly IReadOnlyList<(string Flag, string Path)> SolutionFlags = + [ + ("Storage", "src/common/{P}.Common.Infrastructure/Storage"), + ("ClickHouse", "src/common/{P}.Common.Infrastructure/ClickHouse"), + ("MongoDB", "src/capabilities/{P}.Capabilities.Mongo"), + ("Infisical", "src/common/{P}.Common.Infrastructure/Configuration/Secrets/Infisical*"), + ("Vault", "src/common/{P}.Common.Infrastructure/Configuration/Secrets/Vault*"), + ("DiffApi", "src/common/{P}.Common.Web/Setup/SchemaDump.cs"), + ("FeatureFlags", "src/common/{P}.Common/FeatureManagement"), + ("HierarchyRules", "src/common/{P}.Common/Domain/IHierarchicalEntity.cs"), + ("GitHubCiCd", ".github/workflows/ci.yml"), + ]; + + /// Audit leaves files only with the outbox, which is what the template turns it on with. + public const string AuditPath = "src/common/{P}.Common.Application/Auditing"; + + /// The email channel of --Notify, in the capability of its own since 2.8. + public const string NotifyEmailPath = "src/capabilities/{P}.Capabilities.Notifications"; + + public const string SqlServerPath = "src/common/{P}.Common.Infrastructure/Persistence/SqlServer"; + public const string ComposePath = "deploy/compose"; + public const string KubernetesPath = "deploy/k8s"; + public const string ClaudePath = ".claude"; + public const string OpenCodePath = ".opencode"; + public const string EnvExamplePath = "deploy/compose/.env.example"; + public const string HangfireEnvLine = "HANGFIRE_DASHBOARD_PASSWORD"; + public const string RabbitMqEnvLine = "RABBITMQ_"; + + /// The script that names each image after the product in lower case with underscores: Retail.Shop -> retail_shop. + public const string BuildImagesPath = "deploy/build-images.sh"; + + /// + /// Before 2.8 the template named the solution's first service in a comment of this script, and the check + /// generates the solution with the same first service. Goes in 3.0 with the 2.7 base. + /// + public const string FirstServiceScript = "src/services/manual-add-projects.sh"; + + /// Where the template writes its version into a solution, since 2.7. + public const string BuildPropsPath = "Directory.Build.props"; + public const string VersionElement = "DotNetSolutionKitVersion"; + public const string ServiceVersionElement = "DotNetSolutionKitServiceVersion"; + + public const string ApiFolder = "{S}/{S}.API"; + public const string ApiProject = "{S}/{S}.API/{S}.API.csproj"; + public const string AppSettings = "{S}/{S}.API/appsettings.json"; + public const string LaunchSettings = "{S}/{S}.API/Properties/launchSettings.json"; + public const string ProgramFile = "{S}/{S}.API/Program.cs"; + public const string ConfigurationFile = "{S}/{S}.API/Setup/ApplicationConfiguration.cs"; + public const string TestsProject = "{S}/{S}.Tests/{S}.Tests.csproj"; + + /// A gateway is a service of its own kind, with routes and no domain. + public const string GatewayFile = "{S}/{S}.API/Setup/GatewayTransforms.cs"; + public const string AuditGuardFile = "{S}/{S}.Tests/Tests/AuditGuardTests.cs"; + + /// The flags of a service by the section its appsettings.json has only with them. + public static readonly IReadOnlyList<(string Flag, string Section)> ServiceSections = + [ + ("Storage", "S3"), + ("ClickHouse", "ClickHouse"), + ("MongoDB", "MongoDB"), + ("Infisical", "Infisical"), + ("Vault", "Vault"), + ]; + + public const string HangfireSection = "HangfireSettings"; + public const string BusSection = "RabbitMq"; + public const string EmailSection = "Email"; + + /// Lines of a service's code only one value writes. + public const string OutboxLine = "busNeedsDatabase: true"; + public const string DiffApiLine = "SchemaOnlyMode"; + public const string FeatureFlagsLine = "AddPlatformFeatures"; + public const string XunitLine = "xunit.v3"; + + public static string Of(string path, string prefix, string? service = null) => + path.Replace(Prefix, prefix, StringComparison.Ordinal).Replace(Service, service ?? "", StringComparison.Ordinal); +} diff --git a/tool/DotsKit/Reading/Reproduction.cs b/tool/DotsKit/Reading/Reproduction.cs new file mode 100644 index 0000000..9f55896 --- /dev/null +++ b/tool/DotsKit/Reading/Reproduction.cs @@ -0,0 +1,45 @@ +using DotsKit.Io; +using DotsKit.Merging; + +namespace DotsKit.Reading; + +/// +/// How well a manifest reproduces a solution: of the files the template gives for it, which the solution has +/// as they are. A file that differs is a change of the team or a wrong reading, and a person tells which before +/// anything relies on the manifest. Files the team added are not the template's and are not counted; All.sln is +/// compared by its projects, since its text differs by the GUIDs dotnet sln gives. +/// +internal sealed record Reproduction(int Total, IReadOnlyList Differ) +{ + /// How many of the differing files a report lists; the rest it counts. + public const int Listed = 30; + + public int Reproduced => Total - Differ.Count; + + public bool IsWhole => Differ.Count == 0; + + /// The solution's root. + /// What the template gives for the manifest, generated apart. + /// The projects of the solution's All.sln. + /// The projects of the generated All.sln. + public static Reproduction Of(string solution, string staged, IReadOnlySet solutionProjects, IReadOnlySet stagedProjects) + { + var files = Folders.RelativeFiles(staged).Where(f => !IsSolutionFile(f)).Order(StringComparer.Ordinal).ToList(); + var differ = files.Where(f => FileText.Read(solution, f) is not { } own || !own.SameTextAs(FileText.Read(staged, f)!)).ToList(); + var missingProjects = stagedProjects.Where(p => !solutionProjects.Contains(p)).Order(StringComparer.Ordinal).Select(p => $"All.sln: {p}"); + return new(files.Count + stagedProjects.Count, [.. differ, .. missingProjects]); + } + + private static bool IsSolutionFile(string path) => + path.StartsWith(Layout.ServicesFolder + "/", StringComparison.Ordinal) && path.EndsWith(Layout.SolutionSuffix, StringComparison.Ordinal); + + /// The report's lines: the count, then the files that differ, the first of them. + public IEnumerable Lines() + { + yield return $"{Reproduced} of {Total} files of the template reproduced" + (IsWhole ? "." : "; these differ:"); + foreach (var path in Differ.Take(Listed)) + yield return " " + path; + if (Differ.Count > Listed) + yield return $" and {Differ.Count - Listed} more"; + } +} diff --git a/tool/DotsKit/Reading/SolutionFiles.cs b/tool/DotsKit/Reading/SolutionFiles.cs new file mode 100644 index 0000000..fba8735 --- /dev/null +++ b/tool/DotsKit/Reading/SolutionFiles.cs @@ -0,0 +1,37 @@ +namespace DotsKit.Reading; + +/// The files of a solution as the reading of its manifest sees them, by paths relative to its root with forward slashes. +internal interface ISolutionFiles +{ + /// Whether a file or a folder is there. The last segment may end with *: any name it starts. + bool Exists(string path); + + /// The text of a file, or null when there is none. + string? Read(string path); + + /// The names of the folders inside , in ordinal order; none when it is not there. + IReadOnlyList Folders(string path); +} + +internal sealed class DiskSolutionFiles(string root) : ISolutionFiles +{ + private const char Any = '*'; + + public bool Exists(string path) + { + var full = Full(path); + if (!full.EndsWith(Any)) + return File.Exists(full) || Directory.Exists(full); + var folder = Path.GetDirectoryName(full)!; + return Directory.Exists(folder) && Directory.EnumerateFileSystemEntries(folder, Path.GetFileName(full)).Any(); + } + + public string? Read(string path) => File.Exists(Full(path)) ? File.ReadAllText(Full(path)) : null; + + public IReadOnlyList Folders(string path) => + Directory.Exists(Full(path)) + ? [.. Directory.GetDirectories(Full(path)).Select(Path.GetFileName).OfType().Order(StringComparer.Ordinal)] + : []; + + private string Full(string path) => Path.Combine(root, path); +} diff --git a/tool/DotsKit/Reading/SolutionReader.cs b/tool/DotsKit/Reading/SolutionReader.cs new file mode 100644 index 0000000..2279606 --- /dev/null +++ b/tool/DotsKit/Reading/SolutionReader.cs @@ -0,0 +1,193 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace DotsKit.Reading; + +/// +/// Reads the manifest of a solution made without dotskit from its files: the names from All.sln, the solution's +/// parameters from what Common and the root have, each service by its own files (). Pure: it +/// decides from what gives, and refuses with a line that says what to do. +/// +internal static partial class SolutionReader +{ + private const string On = "true"; + private const string Off = "false"; + private const string ServicesFolder = Layout.ServicesFolder + "/"; + + private static readonly JsonDocumentOptions JsonOptions = new() { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true }; + + /// The image name of build-images.sh: the product in lower case with underscores, then the service. + [GeneratedRegex("""name="(?[a-z0-9_]+)-\$\(""")] + private static partial Regex ImageName(); + + [GeneratedRegex("""localhost:(?\d+)""")] + private static partial Regex LocalPort(); + + /// The version the template wrote into Directory.Build.props, or null when it wrote none (before 2.7). + public static string? Version(ISolutionFiles files) => ElementOf(files.Read(Markers.BuildPropsPath), Markers.VersionElement); + + /// The prefix of the solution, NamespaceRoot.ProductName, from the name of its All.sln. + public static string Prefix(string solutionFileName) => solutionFileName[..^Layout.SolutionSuffix.Length]; + + /// The folders of src/services that are services of the template: each has its API project by its own name. + public static IReadOnlyList ServiceFolders(ISolutionFiles files, string prefix) => + [.. files.Folders(Layout.ServicesFolder) + .Where(f => f.StartsWith(prefix + ".", StringComparison.Ordinal) && files.Exists(ServicePath(Markers.ApiProject, prefix, f)))]; + + /// The name a service was generated with: its folder without the prefix. + public static string ServiceName(string prefix, string folder) => folder[(prefix.Length + 1)..]; + + /// The version a service says it was generated by, or null when it says none. + public static string? ServiceVersion(ISolutionFiles files, string prefix, string folder) => + ElementOf(files.Read(ServicePath(Markers.ApiProject, prefix, folder)), Markers.ServiceVersionElement); + + /// + /// The manifest of the solution: the solution's own parameters, then each service joined to it, the way + /// dotskit new joins them - the solution takes what a service brings outside its folder. + /// + /// What the command names itself: -N and -P where the name of All.sln does not tell them apart. + public static Manifest Read(ISolutionFiles files, string prefix, string version, TemplateArgs given) + { + var services = FirstServiceFirst(files, prefix, ServiceFolders(files, prefix)); + if (services.Count == 0) + throw new InvalidOperationException($"{Layout.ServicesFolder} has no service of the template: dotskit new -S adds one."); + var solution = Solution(files, prefix, Names(files, prefix, given), services[0]); + var manifest = new Manifest { Template = version, Solution = solution.ToArgs(), Services = [] }; + return services.Aggregate(manifest, (m, folder) => m.WithService(Service(files, prefix, folder))); + } + + /// The service folders with the one the solution was made with first, where the solution tells it (). + private static IReadOnlyList FirstServiceFirst(ISolutionFiles files, string prefix, IReadOnlyList services) + { + var script = (files.Read(Markers.FirstServiceScript) ?? "").ReplaceLineEndings("\n").Split('\n'); + var first = services.FirstOrDefault(f => script.Any(l => l.TrimEnd().EndsWith($"# {f}", StringComparison.Ordinal))); + return first is null ? services : [first, .. services.Where(f => f != first)]; + } + + /// A service of the solution by its files, to add to a manifest that does not list it. + public static TemplateArgs Service(ISolutionFiles files, string prefix, string folder) + { + var name = ServiceName(prefix, folder); + var args = TemplateArgs.Empty.With(TemplateArgs.ServiceName, name); + if (Port(files, prefix, folder) is { } port) + args = args.With(TemplateArgs.HttpPort, port); + if (files.Exists(ServicePath(Markers.GatewayFile, prefix, folder))) + return args.With("ApiGateway", On); + var settings = Sections(files.Read(ServicePath(Markers.AppSettings, prefix, folder))); + if (!settings.Contains(Markers.HangfireSection)) + args = args.With("Hangfire", Off); + if (Messaging(files, prefix, folder, settings) is { } messaging) + args = args.With("Messaging", messaging); + foreach (var (flag, section) in Markers.ServiceSections.Where(s => settings.Contains(s.Section))) + args = args.With(flag, On); + if (settings.Contains(Markers.EmailSection)) + args = args.With("Notify", "email"); + if (Contains(files, ServicePath(Markers.ProgramFile, prefix, folder), Markers.DiffApiLine)) + args = args.With("DiffApi", On); + if (Contains(files, ServicePath(Markers.ConfigurationFile, prefix, folder), Markers.FeatureFlagsLine)) + args = args.With("FeatureFlags", On); + if (files.Exists(ServicePath(Markers.AuditGuardFile, prefix, folder))) + args = args.With("Audit", On); + return args; + } + + /// + /// NamespaceRoot and ProductName. The name of All.sln joins them with a dot that a part may have too, so the + /// split is read from the image names of build-images.sh, which write the product alone; two parts split + /// themselves; otherwise the command names them. + /// + private static (string Root, string Product) Names(ISolutionFiles files, string prefix, TemplateArgs given) + { + if (given.Has("NamespaceRoot") && given.Has("ProductName")) + return Checked(prefix, given["NamespaceRoot"]!, given["ProductName"]!); + var parts = prefix.Split('.'); + if (ImageName().Match(files.Read(Markers.BuildImagesPath) ?? "") is { Success: true } image) + { + // The image writes the product in lower case with each dot as an underscore: the cut whose tail gives it. + var product = image.Groups["product"].Value; + for (var cut = 1; cut < parts.Length; cut++) + if (string.Join('_', parts[cut..]).ToLowerInvariant() == product) + return Checked(prefix, string.Join('.', parts[..cut]), string.Join('.', parts[cut..])); + } + if (parts.Length == 2) + return (parts[0], parts[1]); + throw new ArgumentException($"{prefix} does not tell its NamespaceRoot from its ProductName: name them, dotskit init -N -P ."); + } + + private static (string Root, string Product) Checked(string prefix, string root, string product) => + string.Equals($"{root}.{product}", prefix, StringComparison.Ordinal) + ? (root, product) + : throw new ArgumentException($"-N {root} -P {product} make {root}.{product}, the solution is {prefix}: name its own parts."); + + /// The parameters of the whole solution, and the flags Common and the root were generated with. + private static TemplateArgs Solution(ISolutionFiles files, string prefix, (string Root, string Product) names, string firstService) + { + var args = TemplateArgs.Empty.With("NamespaceRoot", names.Root).With("ProductName", names.Product); + args = NotDefault(args, "Database", files.Exists(Markers.Of(Markers.SqlServerPath, prefix)) ? "mssql" : "postgres"); + args = NotDefault(args, "Deploy", files.Exists(Markers.ComposePath) ? "compose" : files.Exists(Markers.KubernetesPath) ? "k8s" : "none"); + args = NotDefault(args, "Agent", files.Exists(Markers.ClaudePath) ? "claude" : files.Exists(Markers.OpenCodePath) ? "opencode" : "none"); + args = NotDefault(args, "TestFramework", Contains(files, ServicePath(Markers.TestsProject, prefix, firstService), Markers.XunitLine) ? "xunit" : "nunit"); + foreach (var (flag, path) in Markers.SolutionFlags.Where(m => files.Exists(Markers.Of(m.Path, prefix)))) + args = args.With(flag, On); + if (files.Exists(Markers.Of(Markers.NotifyEmailPath, prefix))) + args = args.With("Notify", "email"); + if (files.Exists(Markers.Of(Markers.AuditPath, prefix))) + args = args.With("Audit", On).With("Messaging", "outbox"); + return RootOnly(files, args); + } + + /// + /// Hangfire and the bus of the solution show only in .env.example of compose; elsewhere the services' own + /// give them, as dotskit new widens the solution by each service. + /// + private static TemplateArgs RootOnly(ISolutionFiles files, TemplateArgs args) + { + if (files.Read(Markers.EnvExamplePath) is not { } env) + return args.With("Hangfire", Off); + args = NotDefault(args, "Hangfire", env.Contains(Markers.HangfireEnvLine, StringComparison.Ordinal) ? On : Off); + return env.Contains(Markers.RabbitMqEnvLine, StringComparison.Ordinal) && !args.Has("Messaging") ? args.With("Messaging", "direct") : args; + } + + /// The value, given only where it is not the template's default: a manifest names what was chosen, as dotskit new writes it. + private static TemplateArgs NotDefault(TemplateArgs args, string name, string value) => + string.Equals(TemplateArgs.Empty[name], value, StringComparison.OrdinalIgnoreCase) ? args : args.With(name, value); + + private static string? Messaging(ISolutionFiles files, string prefix, string folder, IReadOnlySet settings) => + !settings.Contains(Markers.BusSection) ? null + : Contains(files, ServicePath(Markers.ConfigurationFile, prefix, folder), Markers.OutboxLine) ? "outbox" + : "direct"; + + private static string? Port(ISolutionFiles files, string prefix, string folder) => + LocalPort().Match(files.Read(ServicePath(Markers.LaunchSettings, prefix, folder)) ?? "") is { Success: true } m ? m.Groups["port"].Value : null; + + /// The top-level sections of appsettings.json; none when it is gone or no longer JSON. + private static HashSet Sections(string? json) + { + if (json is null) + return []; + try + { + using var document = JsonDocument.Parse(json.TrimStart(''), JsonOptions); + return document.RootElement.ValueKind == JsonValueKind.Object + ? document.RootElement.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal) + : []; + } + catch (JsonException) + { + return []; + } + } + + private static bool Contains(ISolutionFiles files, string path, string text) => + files.Read(path)?.Contains(text, StringComparison.Ordinal) == true; + + private static string ServicePath(string path, string prefix, string folder) => ServicesFolder + Markers.Of(path, prefix, folder); + + private static string? ElementOf(string? xml, string element) + { + if (xml is null) + return null; + var match = Regex.Match(xml, $"<{element}>([^<]+)"); + return match.Success ? match.Groups[1].Value.Trim() : null; + } +}