From 1aa3d53124f64e91353467e341908730d64e7d49 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Thu, 16 Jul 2026 21:51:10 +0200 Subject: [PATCH] Decompose Fallout.Migrate into an IMigrationStep pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration.Run() hard-coded four migration actions as private methods sharing private helpers, making it awkward to add the steps planned next. Split into: - Common/ -- MigrationContext (plain data), Summary, RewriteResult,   IMigrationStep, and MigrationFileOperations (shared EnumerateFiles /   ApplyRewrite / RelativePath helpers) - Steps/ -- one class per step (ResolveFalloutVersionStep,   RewriteCsprojsStep, RewriteCsFilesStep, RewriteBootstrapScriptsStep,   RenameNukeDirectoryStep), each paired with the rewriter it drives   (CsprojRewriter, CodeRewriter, ScriptRewriter) Migration.Run() now just builds a MigrationContext and iterates a fixed, ordered step list. Adding a future step is one new class plus one line in that list. No behavior change; MigrationIntegrationSpecs pass unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Fallout.Migrate/Common/IMigrationStep.cs | 18 ++ .../Common/MigrationContext.cs | 29 +++ .../Common/MigrationFileOperations.cs | 100 ++++++++ src/Fallout.Migrate/Common/RewriteResult.cs | 9 + src/Fallout.Migrate/Common/Summary.cs | 22 ++ src/Fallout.Migrate/MigrateCommand.cs | 39 +++- src/Fallout.Migrate/MigrateSettings.cs | 10 + src/Fallout.Migrate/Migration.cs | 215 +++--------------- src/Fallout.Migrate/Program.cs | 13 +- .../{ => Steps}/CodeRewriter.cs | 14 +- .../{ => Steps}/CsprojRewriter.cs | 16 +- .../Steps/RenameNukeDirectoryStep.cs | 46 ++++ .../Steps/ResolveFalloutVersionStep.cs | 59 +++++ .../Steps/RewriteBootstrapScriptsStep.cs | 32 +++ .../Steps/RewriteCsFilesStep.cs | 21 ++ .../Steps/RewriteCsprojsStep.cs | 25 ++ .../{ => Steps}/ScriptRewriter.cs | 15 +- .../CodeRewriterSpecs.cs | 1 + .../CsprojRewriterSpecs.cs | 1 + .../MigrationIntegrationSpecs.cs | 13 +- .../ScriptRewriterSpecs.cs | 1 + 21 files changed, 502 insertions(+), 197 deletions(-) create mode 100644 src/Fallout.Migrate/Common/IMigrationStep.cs create mode 100644 src/Fallout.Migrate/Common/MigrationContext.cs create mode 100644 src/Fallout.Migrate/Common/MigrationFileOperations.cs create mode 100644 src/Fallout.Migrate/Common/RewriteResult.cs create mode 100644 src/Fallout.Migrate/Common/Summary.cs rename src/Fallout.Migrate/{ => Steps}/CodeRewriter.cs (65%) rename src/Fallout.Migrate/{ => Steps}/CsprojRewriter.cs (80%) create mode 100644 src/Fallout.Migrate/Steps/RenameNukeDirectoryStep.cs create mode 100644 src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs create mode 100644 src/Fallout.Migrate/Steps/RewriteBootstrapScriptsStep.cs create mode 100644 src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs create mode 100644 src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs rename src/Fallout.Migrate/{ => Steps}/ScriptRewriter.cs (62%) diff --git a/src/Fallout.Migrate/Common/IMigrationStep.cs b/src/Fallout.Migrate/Common/IMigrationStep.cs new file mode 100644 index 000000000..bf98cc9a5 --- /dev/null +++ b/src/Fallout.Migrate/Common/IMigrationStep.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; + +namespace Fallout.Migrate.Common; + +/// +/// One unit of work performed against a repo during fallout-migrate. Add a new step by +/// implementing this interface and registering it in 's step list. +/// +internal interface IMigrationStep +{ + /// + /// Performs this step's work against , recording the outcome in + /// . + /// + /// The current migration context. + /// The summary to update with files changed, edits made, or warnings. + Task ExecuteAsync(MigrationContext context, Summary summary); +} diff --git a/src/Fallout.Migrate/Common/MigrationContext.cs b/src/Fallout.Migrate/Common/MigrationContext.cs new file mode 100644 index 000000000..dab38e49d --- /dev/null +++ b/src/Fallout.Migrate/Common/MigrationContext.cs @@ -0,0 +1,29 @@ +using System; +using System.IO; +using Fallout.Common.IO; + +namespace Fallout.Migrate.Common; + +/// +/// Plain data carried between and each . +/// Holds no behavior itself; see for the shared +/// file-walking / rewrite-application helpers steps call into. +/// +internal sealed class MigrationContext(AbsolutePath rootDirectory, bool dryRun, TextWriter log) +{ + /// The repository root being migrated. + public AbsolutePath RootDirectory { get; } = rootDirectory ?? throw new ArgumentNullException(nameof(rootDirectory)); + + /// When true, steps must report intended changes without writing them. + public bool DryRun { get; } = dryRun; + + /// The writer steps use to report progress. + public TextWriter Log { get; } = log ?? throw new ArgumentNullException(nameof(log)); + + /// + /// The Fallout version to pin in rewritten package references. + /// Set by , which always runs first; + /// subsequent steps read it. + /// + public string FalloutVersion { get; internal set; } +} diff --git a/src/Fallout.Migrate/Common/MigrationFileOperations.cs b/src/Fallout.Migrate/Common/MigrationFileOperations.cs new file mode 100644 index 000000000..a5ba5e44b --- /dev/null +++ b/src/Fallout.Migrate/Common/MigrationFileOperations.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Fallout.Common.IO; + +namespace Fallout.Migrate.Common; + +/// +/// Shared file-walking and rewrite-application helpers used by multiple +/// implementations. Kept as static, dependency-free functions so steps stay small and independent. +/// +internal static class MigrationFileOperations +{ + /// + /// Recursively enumerates files under matching + /// , skipping bin/, obj/, and .git/. + /// + /// The directory to search from. + /// A file-name glob pattern, e.g. *.csproj. + /// The matching, non-ignored files. + public static IEnumerable EnumerateFiles(AbsolutePath rootDirectory, string pattern) + { + foreach (var file in rootDirectory.GetFiles(pattern, depth: int.MaxValue)) + { + if (IsIgnored(file)) + { + continue; + } + + yield return file; + } + } + + /// + /// Reads , applies , logs and records the edit, + /// and writes the result back unless is set. + /// + /// The current migration context. + /// The file to rewrite. + /// The rewrite function to apply to the file's content. + /// The summary to update with the outcome. + public static void ApplyRewrite( + MigrationContext context, + AbsolutePath path, + Func rewriter, + Summary summary) + { + string original; + try + { + original = path.ReadAllText(); + } + catch (IOException ex) + { + summary.Warnings.Add($"could not read {RelativePath(context.RootDirectory, path)}: {ex.Message}"); + return; + } + + var result = rewriter(original); + if (result.EditCount == 0) + { + return; + } + + context.Log.WriteLine( + $"edit {RelativePath(context.RootDirectory, path)} ({result.EditCount} change{(result.EditCount == 1 ? "" : "s")})"); + + summary.FilesChanged++; + summary.EditCount += result.EditCount; + + if (!context.DryRun) + { + // eofLineBreak: false preserves today's exact byte-for-byte write behavior; + // AbsolutePath.WriteAllText's default normalizes trailing line endings, which + // would otherwise sneak unrelated diff noise into migrated consumer repos. + path.WriteAllText(result.Content, eofLineBreak: false); + } + } + + /// + /// Formats as a path relative to , + /// using forward slashes, for log output. + /// + /// The directory to make the path relative to. + /// The path to format. + public static string RelativePath(AbsolutePath rootDirectory, AbsolutePath absolute) => + rootDirectory.GetUnixRelativePathTo(absolute); + + /// + /// Returns true if sits under a bin/, obj/, or + /// .git/ directory and should be skipped by . + /// + private static bool IsIgnored(AbsolutePath path) + { + string text = path; + return text.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + || text.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + || text.Contains($"{Path.DirectorySeparatorChar}.git{Path.DirectorySeparatorChar}", StringComparison.Ordinal); + } +} diff --git a/src/Fallout.Migrate/Common/RewriteResult.cs b/src/Fallout.Migrate/Common/RewriteResult.cs new file mode 100644 index 000000000..4c1de5717 --- /dev/null +++ b/src/Fallout.Migrate/Common/RewriteResult.cs @@ -0,0 +1,9 @@ +namespace Fallout.Migrate.Common; + +/// +/// The result of rewriting a file's content: the new and how many +/// individual edits were made. +/// +/// The rewritten file content. +/// The number of edits applied; zero means the content is unchanged. +internal readonly record struct RewriteResult(string Content, int EditCount); diff --git a/src/Fallout.Migrate/Common/Summary.cs b/src/Fallout.Migrate/Common/Summary.cs new file mode 100644 index 000000000..c554568d7 --- /dev/null +++ b/src/Fallout.Migrate/Common/Summary.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace Fallout.Migrate.Common; + +/// +/// Aggregate result of a migration run: how many files changed, how many edits were made, how many +/// directories were renamed, and any warnings that need manual follow-up. +/// +internal sealed class Summary +{ + /// The number of files that had at least one edit applied. + public int FilesChanged { get; set; } + + /// The total number of individual edits made across all rewritten files. + public int EditCount { get; set; } + + /// The number of directories renamed (currently just .nuke/.fallout/). + public int DirectoriesRenamed { get; set; } + + /// Human-readable warnings about conditions the migration could not resolve automatically. + public List Warnings { get; } = new(); +} diff --git a/src/Fallout.Migrate/MigrateCommand.cs b/src/Fallout.Migrate/MigrateCommand.cs index 6338ca6a5..e7d2cbc2a 100644 --- a/src/Fallout.Migrate/MigrateCommand.cs +++ b/src/Fallout.Migrate/MigrateCommand.cs @@ -1,13 +1,19 @@ using System; using System.ComponentModel; using System.IO; +using System.Threading.Tasks; using Fallout.Common; using Fallout.Common.IO; +using Fallout.Migrate.Common; using JetBrains.Annotations; using Spectre.Console.Cli; namespace Fallout.Migrate; +/// +/// The fallout-migrate CLI command. See the below for the +/// user-facing summary of what the migration does. +/// [Description(""" Migrate a NUKE consumer repo to Fallout. @@ -20,9 +26,15 @@ in build.cmd / build.ps1 / build.sh - Prints a summary of files changed and warnings to address manually """)] [UsedImplicitly] -internal sealed class MigrateCommand : Command +internal sealed class MigrateCommand : AsyncCommand { - public override int Execute(CommandContext context, MigrateSettings settings) + /// + /// Resolves the repository root, runs the migration, and prints a summary. + /// + /// The Spectre.Console.Cli command context (unused). + /// The parsed for this invocation. + /// 0 on success, 1 if the repository root could not be resolved. + public override async Task ExecuteAsync(CommandContext context, MigrateSettings settings) { var rootDirectory = ResolveRootDirectory(settings.Path); if (rootDirectory is null) @@ -37,12 +49,18 @@ public override int Execute(CommandContext context, MigrateSettings settings) PrintBanner(rootDirectory, settings.DryRun); var migration = new Migration(rootDirectory, settings.DryRun, Console.Out); - var summary = migration.Run(); + Summary summary = await migration.RunAsync(); PrintSummary(summary, settings.DryRun); return 0; } + /// + /// Resolves the repository root to migrate: the explicit if given, + /// otherwise the nearest ancestor of the working directory that looks like a Fallout/NUKE build repo. + /// + /// The path argument passed on the command line, or null. + /// The resolved repository root, or null if none could be found. private static AbsolutePath ResolveRootDirectory(string explicitArg) { if (explicitArg != null) @@ -59,6 +77,11 @@ private static AbsolutePath ResolveRootDirectory(string explicitArg) (current / "build.sh").FileExists()); } + /// + /// Prints the tool banner and, when applicable, the dry-run notice. + /// + /// The repository root being migrated. + /// Whether the migration is running in dry-run mode. private static void PrintBanner(AbsolutePath rootDirectory, bool dryRun) { Console.WriteLine($"fallout-migrate — migrating: {rootDirectory}"); @@ -70,12 +93,18 @@ private static void PrintBanner(AbsolutePath rootDirectory, bool dryRun) Console.WriteLine(); } - private static void PrintSummary(Migration.Summary summary, bool dryRun) + /// + /// Prints the migration (counts, warnings, and next-steps guidance) to the console. + /// + /// The result of . + /// Whether the migration ran in dry-run mode. + private static void PrintSummary(Summary summary, bool dryRun) { Console.WriteLine(); Console.WriteLine($"Files changed: {summary.FilesChanged}"); Console.WriteLine($"Edits made: {summary.EditCount}"); Console.WriteLine($"Directories: {summary.DirectoriesRenamed} renamed"); + if (summary.Warnings.Count > 0) { Console.WriteLine(); @@ -89,7 +118,7 @@ private static void PrintSummary(Migration.Summary summary, bool dryRun) Console.WriteLine(); Console.WriteLine(dryRun ? "Dry-run complete. Re-run without --dry-run to apply changes." - : "Migration complete. Verify the build: ./build.ps1 (or ./build.sh on unix)"); + : "Migration complete. Verify the build: ./build.ps1 (or ./build.sh on UNIX)"); Console.WriteLine("Migration guide: https://fallout.build (see #37 for the full guide)"); } diff --git a/src/Fallout.Migrate/MigrateSettings.cs b/src/Fallout.Migrate/MigrateSettings.cs index fc62a524d..8790da1cc 100644 --- a/src/Fallout.Migrate/MigrateSettings.cs +++ b/src/Fallout.Migrate/MigrateSettings.cs @@ -4,14 +4,24 @@ namespace Fallout.Migrate; +/// +/// Command-line arguments accepted by . +/// [UsedImplicitly] internal sealed class MigrateSettings : CommandSettings { + /// + /// The repository root to migrate. When null, resolves it by + /// walking up from the working directory. + /// [CommandArgument(0, "[path]")] [Description("Repository root. Defaults to walking up from the working directory to find " + "one (looking for build.cmd / build.ps1 / build.sh, .nuke/, or build/).")] public string Path { get; init; } + /// + /// When true, reports the changes would make without writing them. + /// [CommandOption("-n|--dry-run")] [Description("Show what would change without writing.")] public bool DryRun { get; init; } diff --git a/src/Fallout.Migrate/Migration.cs b/src/Fallout.Migrate/Migration.cs index cf26e57a9..d54d854e8 100644 --- a/src/Fallout.Migrate/Migration.cs +++ b/src/Fallout.Migrate/Migration.cs @@ -1,196 +1,49 @@ -using System; using System.Collections.Generic; using System.IO; -using System.Linq; -using System.Reflection; +using System.Threading.Tasks; using Fallout.Common.IO; +using Fallout.Migrate.Common; +using Fallout.Migrate.Steps; namespace Fallout.Migrate; -internal sealed class Migration +/// +/// Orchestrates a migration run: builds a and executes the fixed, +/// ordered list of s against it. Add a new step by implementing +/// and appending it to . +/// +/// The repository root to migrate. +/// When true, reports intended changes without writing them. +/// The writer steps use to report progress. +internal sealed class Migration(AbsolutePath rootDirectory, bool dryRun, TextWriter log) { - private readonly AbsolutePath rootDirectory; - private readonly bool dryRun; - private readonly TextWriter log; - - public Migration(AbsolutePath rootDirectory, bool dryRun, TextWriter log) - { - this.rootDirectory = rootDirectory ?? throw new ArgumentNullException(nameof(rootDirectory)); - this.dryRun = dryRun; - this.log = log ?? throw new ArgumentNullException(nameof(log)); - } - - public Summary Run() + /// + /// The steps executed by , in order. must + /// run first, since later steps read from it. + /// + private static readonly IReadOnlyList steps = + [ + new ResolveFalloutVersionStep(), + new RewriteCsprojsStep(), + new RewriteCsFilesStep(), + new RewriteBootstrapScriptsStep(), + new RenameNukeDirectoryStep() + ]; + + /// + /// Runs every step in against a fresh . + /// + /// A of the files changed, edits made, and any warnings. + public async Task RunAsync() { var summary = new Summary(); + var context = new MigrationContext(rootDirectory, dryRun, log); - RewriteCsprojs(summary); - RewriteCsFiles(summary); - RewriteBootstrapScripts(summary); - RenameNukeDirectory(summary); - - return summary; - } - - private void RewriteCsprojs(Summary summary) - { - var falloutVersion = ResolveFalloutVersion(); - foreach (var path in EnumerateFiles("*.csproj")) - { - ApplyRewrite(path, content => CsprojRewriter.Rewrite(content, falloutVersion), summary); - } - } - - // Pinned into migrated `` lines. - // Uses the running migrate tool's own SemVer (Nerdbank.GitVersioning, set on - // AssemblyInformationalVersion) so the migration output aligns with the tool the user - // just installed. For dev/local builds without a `+` in InformationalVersion (i.e. no - // build-metadata suffix), falls back to a known-published floor so we never emit a - // bogus pin like Version="LOCAL". Inlined to keep Fallout.Migrate dependency-free. - private static string ResolveFalloutVersion() - { - const string fallback = "10.3.49"; - - var informational = typeof(Migration).Assembly - .GetCustomAttribute() - ?.InformationalVersion; - - if (string.IsNullOrEmpty(informational)) - { - return fallback; - } - - int plusIndex = informational.IndexOf('+'); - if (plusIndex == -1) - { - return fallback; - } - - return informational[..plusIndex]; - } - - private void RewriteCsFiles(Summary summary) - { - foreach (var path in EnumerateFiles("*.cs")) - { - ApplyRewrite(path, CodeRewriter.Rewrite, summary); - } - } - - private void RewriteBootstrapScripts(Summary summary) - { - foreach (var name in new[] - { - "build.cmd", - "build.ps1", - "build.sh" - }) - { - var path = rootDirectory / name; - if (path.FileExists()) - { - ApplyRewrite(path, ScriptRewriter.Rewrite, summary); - } - } - } - - private void RenameNukeDirectory(Summary summary) - { - var legacy = rootDirectory / ".nuke"; - var canonical = rootDirectory / ".fallout"; - - if (!legacy.DirectoryExists()) - { - return; - } - - if (canonical.DirectoryExists()) - { - summary.Warnings.Add( - "Both .nuke/ and .fallout/ exist. Skipped rename; merge their contents manually."); - - return; - } - - Log($"rename {RelativePath(legacy)} -> {RelativePath(canonical)}"); - if (!dryRun) - { - // We purposely use the .NET directory move as this is atomic. Fallout's own - // Move/MoveDirectory moves them one by one. - Directory.Move(legacy, canonical); - } - - summary.DirectoriesRenamed++; - } - - private IEnumerable EnumerateFiles(string pattern) - { - foreach (var file in rootDirectory.GetFiles(pattern, depth: int.MaxValue)) - { - if (IsIgnored(file)) - { - continue; - } - - yield return file; - } - } - - private static bool IsIgnored(AbsolutePath path) - { - string text = path; - return text.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal) - || text.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) - || text.Contains($"{Path.DirectorySeparatorChar}.git{Path.DirectorySeparatorChar}", StringComparison.Ordinal); - } - - private void ApplyRewrite(AbsolutePath path, Func rewriter, Summary summary) - { - string original; - try - { - original = path.ReadAllText(); - } - catch (IOException ex) - { - summary.Warnings.Add($"could not read {RelativePath(path)}: {ex.Message}"); - return; - } - - var result = rewriter(original); - if (result.EditCount == 0) + foreach (var step in steps) { - return; + await step.ExecuteAsync(context, summary); } - Log($"edit {RelativePath(path)} ({result.EditCount} change{(result.EditCount == 1 ? "" : "s")})"); - summary.FilesChanged++; - summary.EditCount += result.EditCount; - - if (!dryRun) - { - // eofLineBreak: false preserves today's exact byte-for-byte write behavior; - // AbsolutePath.WriteAllText's default normalizes trailing line endings, which - // would otherwise sneak unrelated diff noise into migrated consumer repos. - path.WriteAllText(result.Content, eofLineBreak: false); - } - } - - private string RelativePath(AbsolutePath absolute) => - rootDirectory.GetUnixRelativePathTo(absolute); - - private void Log(string line) => log.WriteLine(line); - - internal sealed class Summary - { - public int FilesChanged { get; set; } - - public int EditCount { get; set; } - - public int DirectoriesRenamed { get; set; } - - public List Warnings { get; } = new(); + return summary; } } - -internal readonly record struct RewriteResult(string Content, int EditCount); diff --git a/src/Fallout.Migrate/Program.cs b/src/Fallout.Migrate/Program.cs index 0a0075566..0d73df98b 100644 --- a/src/Fallout.Migrate/Program.cs +++ b/src/Fallout.Migrate/Program.cs @@ -1,10 +1,19 @@ +using System.Threading.Tasks; using Spectre.Console.Cli; namespace Fallout.Migrate; +/// +/// Entry point for the fallout-migrate CLI tool. +/// public static class Program { - public static int Main(string[] args) + /// + /// Configures and runs the as a single-command Spectre.Console.Cli app. + /// + /// The command-line arguments passed to the tool. + /// The process exit code. + public static async Task Main(string[] args) { var app = new CommandApp(); app.Configure(config => @@ -14,6 +23,6 @@ public static int Main(string[] args) config.AddExample("path/to/repo"); }); - return app.Run(args); + return await app.RunAsync(args); } } diff --git a/src/Fallout.Migrate/CodeRewriter.cs b/src/Fallout.Migrate/Steps/CodeRewriter.cs similarity index 65% rename from src/Fallout.Migrate/CodeRewriter.cs rename to src/Fallout.Migrate/Steps/CodeRewriter.cs index c32c6a7c2..d90858e38 100644 --- a/src/Fallout.Migrate/CodeRewriter.cs +++ b/src/Fallout.Migrate/Steps/CodeRewriter.cs @@ -1,7 +1,13 @@ using System.Text.RegularExpressions; +using Fallout.Migrate.Common; -namespace Fallout.Migrate; +namespace Fallout.Migrate.Steps; +/// +/// Rewrites .cs files: Nuke.* namespace prefixes become Fallout., and the bare +/// NukeBuild/INukeBuild types become FalloutBuild/IFalloutBuild. +/// Driven by . +/// internal static class CodeRewriter { // Anchored prefix swap: `\bNuke\.` → `Fallout.`. Covers using directives, @@ -15,6 +21,12 @@ internal static class CodeRewriter private static readonly Regex nukeBuildType = new(@"\bNukeBuild\b", RegexOptions.Compiled); private static readonly Regex iNukeBuildType = new(@"\bINukeBuild\b", RegexOptions.Compiled); + /// + /// Rewrites C# source, replacing Nuke.* references and the + /// bare NUKE build types with their Fallout equivalents. + /// + /// The original .cs file content. + /// The rewritten content and the number of edits made. public static RewriteResult Rewrite(string original) { var edits = 0; diff --git a/src/Fallout.Migrate/CsprojRewriter.cs b/src/Fallout.Migrate/Steps/CsprojRewriter.cs similarity index 80% rename from src/Fallout.Migrate/CsprojRewriter.cs rename to src/Fallout.Migrate/Steps/CsprojRewriter.cs index 6ece15252..15ddae94f 100644 --- a/src/Fallout.Migrate/CsprojRewriter.cs +++ b/src/Fallout.Migrate/Steps/CsprojRewriter.cs @@ -1,7 +1,14 @@ using System.Text.RegularExpressions; +using Fallout.Migrate.Common; -namespace Fallout.Migrate; +namespace Fallout.Migrate.Steps; +/// +/// Rewrites .csproj files: Nuke.* package/project references become Fallout.* +/// (pinning the current Fallout version where an inline Version attribute was present), +/// Nuke* MSBuild properties are renamed to Fallout*, and stale explicit +/// System.Security.Cryptography.Xml pins are stripped. Driven by . +/// internal static class CsprojRewriter { // Combined rewrite: Nuke.X PackageReference WITH an inline Version attribute → Fallout.X @@ -43,6 +50,13 @@ internal static class CsprojRewriter @"^[ \t]*\s*\r?\n?", RegexOptions.Compiled | RegexOptions.Multiline); + /// + /// Rewrites content, replacing Nuke.* references and MSBuild + /// properties with their Fallout.* equivalents and stripping stale pins. + /// + /// The original .csproj file content. + /// The Fallout version to pin into rewritten inline-versioned references. + /// The rewritten content and the number of edits made. public static RewriteResult Rewrite(string original, string falloutVersion) { var edits = 0; diff --git a/src/Fallout.Migrate/Steps/RenameNukeDirectoryStep.cs b/src/Fallout.Migrate/Steps/RenameNukeDirectoryStep.cs new file mode 100644 index 000000000..a473895ea --- /dev/null +++ b/src/Fallout.Migrate/Steps/RenameNukeDirectoryStep.cs @@ -0,0 +1,46 @@ +using System.IO; +using System.Threading.Tasks; +using Fallout.Common.IO; +using Fallout.Migrate.Common; + +namespace Fallout.Migrate.Steps; + +/// +/// Renames the repository's .nuke/ directory to .fallout/, or records a warning if +/// both already exist and need a manual merge. +/// +internal sealed class RenameNukeDirectoryStep : IMigrationStep +{ + /// + public Task ExecuteAsync(MigrationContext context, Summary summary) + { + var legacy = context.RootDirectory / ".nuke"; + var canonical = context.RootDirectory / ".fallout"; + + if (!legacy.DirectoryExists()) + { + return Task.CompletedTask; + } + + if (canonical.DirectoryExists()) + { + summary.Warnings.Add( + "Both .nuke/ and .fallout/ exist. Skipped rename; merge their contents manually."); + + return Task.CompletedTask; + } + + context.Log.WriteLine( + $"rename {MigrationFileOperations.RelativePath(context.RootDirectory, legacy)} -> {MigrationFileOperations.RelativePath(context.RootDirectory, canonical)}"); + + if (!context.DryRun) + { + // We purposely use the .NET directory move as this is atomic. Fallout's own + // Move/MoveDirectory moves them one by one. + Directory.Move(legacy, canonical); + } + + summary.DirectoriesRenamed++; + return Task.CompletedTask; + } +} diff --git a/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs b/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs new file mode 100644 index 000000000..d51c26a0c --- /dev/null +++ b/src/Fallout.Migrate/Steps/ResolveFalloutVersionStep.cs @@ -0,0 +1,59 @@ +using System.Reflection; +using System.Threading.Tasks; +using Fallout.Migrate.Common; + +namespace Fallout.Migrate.Steps; + +// Pinned into migrated `` lines. + +// Uses the running migrate tool's own SemVer (Nerdbank.GitVersioning, set on + +// AssemblyInformationalVersion) so the migration output aligns with the tool the user + +// just installed. For dev/local builds without a `+` in InformationalVersion (i.e. no + +// build-metadata suffix), falls back to a known-published floor so we never emit a + +// bogus pin like Version="LOCAL". Inlined to keep Fallout.Migrate dependency-free. +/// +/// Resolves the running tool's own Fallout version and stores it on . +/// Must run first in : reads the +/// resolved version to pin rewritten package references. +/// +internal sealed class ResolveFalloutVersionStep : IMigrationStep +{ + /// The version to fall back to when the assembly carries no build-metadata suffix. + private const string Fallback = "10.3.49"; + + /// + public Task ExecuteAsync(MigrationContext context, Summary summary) + { + context.FalloutVersion = Resolve(); + return Task.CompletedTask; + } + + /// + /// Reads the tool assembly's and strips the + /// build-metadata suffix, falling back to when none is present. + /// + /// The Fallout version to pin into rewritten package references. + private static string Resolve() + { + var informational = typeof(ResolveFalloutVersionStep).Assembly + .GetCustomAttribute() + ?.InformationalVersion; + + if (string.IsNullOrEmpty(informational)) + { + return Fallback; + } + + int plusIndex = informational.IndexOf('+'); + if (plusIndex == -1) + { + return Fallback; + } + + return informational[..plusIndex]; + } +} diff --git a/src/Fallout.Migrate/Steps/RewriteBootstrapScriptsStep.cs b/src/Fallout.Migrate/Steps/RewriteBootstrapScriptsStep.cs new file mode 100644 index 000000000..1e1fb5dfe --- /dev/null +++ b/src/Fallout.Migrate/Steps/RewriteBootstrapScriptsStep.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; +using Fallout.Common.IO; +using Fallout.Migrate.Common; + +namespace Fallout.Migrate.Steps; + +/// +/// Rewrites build.cmd, build.ps1, and build.sh at the repository root, when +/// present, via . +/// +internal sealed class RewriteBootstrapScriptsStep : IMigrationStep +{ + /// + public Task ExecuteAsync(MigrationContext context, Summary summary) + { + foreach (var name in new[] + { + "build.cmd", + "build.ps1", + "build.sh" + }) + { + var path = context.RootDirectory / name; + if (path.FileExists()) + { + MigrationFileOperations.ApplyRewrite(context, path, ScriptRewriter.Rewrite, summary); + } + } + + return Task.CompletedTask; + } +} diff --git a/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs b/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs new file mode 100644 index 000000000..235e35edc --- /dev/null +++ b/src/Fallout.Migrate/Steps/RewriteCsFilesStep.cs @@ -0,0 +1,21 @@ +using System.Threading.Tasks; +using Fallout.Migrate.Common; + +namespace Fallout.Migrate.Steps; + +/// +/// Rewrites every *.cs file under the repository root via . +/// +internal sealed class RewriteCsFilesStep : IMigrationStep +{ + /// + public Task ExecuteAsync(MigrationContext context, Summary summary) + { + foreach (var path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, "*.cs")) + { + MigrationFileOperations.ApplyRewrite(context, path, CodeRewriter.Rewrite, summary); + } + + return Task.CompletedTask; + } +} diff --git a/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs b/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs new file mode 100644 index 000000000..aed7ea78b --- /dev/null +++ b/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using Fallout.Migrate.Common; + +namespace Fallout.Migrate.Steps; + +/// +/// Rewrites every *.csproj file under the repository root via . +/// +internal sealed class RewriteCsprojsStep : IMigrationStep +{ + /// + public Task ExecuteAsync(MigrationContext context, Summary summary) + { + foreach (var path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, "*.csproj")) + { + MigrationFileOperations.ApplyRewrite( + context, + path, + content => CsprojRewriter.Rewrite(content, context.FalloutVersion), + summary); + } + + return Task.CompletedTask; + } +} diff --git a/src/Fallout.Migrate/ScriptRewriter.cs b/src/Fallout.Migrate/Steps/ScriptRewriter.cs similarity index 62% rename from src/Fallout.Migrate/ScriptRewriter.cs rename to src/Fallout.Migrate/Steps/ScriptRewriter.cs index 65e699ab8..ac3fbe25a 100644 --- a/src/Fallout.Migrate/ScriptRewriter.cs +++ b/src/Fallout.Migrate/Steps/ScriptRewriter.cs @@ -1,9 +1,16 @@ using System.Text.RegularExpressions; +using Fallout.Migrate.Common; -namespace Fallout.Migrate; +namespace Fallout.Migrate.Steps; +/// +/// Rewrites bootstrap scripts (build.cmd/build.ps1/build.sh): dotnet nuke +/// invocations, .nuke path references, and legacy NUKE_* environment variables become +/// their Fallout equivalents. Driven by . +/// internal static class ScriptRewriter { + /// The ordered find/replace patterns applied by . private static readonly (Regex Pattern, string Replacement)[] patterns = { // `dotnet nuke` invocations @@ -17,6 +24,12 @@ private static readonly (Regex Pattern, string Replacement)[] patterns = (new Regex(@"\bNUKE_INTERNAL_INTERCEPTOR\b", RegexOptions.Compiled), "FALLOUT_INTERNAL_INTERCEPTOR"), }; + /// + /// Rewrites script content, applying every pattern in + /// in order. + /// + /// The original script file content. + /// The rewritten content and the number of edits made. public static RewriteResult Rewrite(string original) { var edits = 0; diff --git a/tests/Fallout.Migrate.Specs/CodeRewriterSpecs.cs b/tests/Fallout.Migrate.Specs/CodeRewriterSpecs.cs index 1ecf70386..37bab7dcc 100644 --- a/tests/Fallout.Migrate.Specs/CodeRewriterSpecs.cs +++ b/tests/Fallout.Migrate.Specs/CodeRewriterSpecs.cs @@ -1,5 +1,6 @@ using FluentAssertions; using Xunit; +using Fallout.Migrate.Steps; namespace Fallout.Migrate.Specs; diff --git a/tests/Fallout.Migrate.Specs/CsprojRewriterSpecs.cs b/tests/Fallout.Migrate.Specs/CsprojRewriterSpecs.cs index 6579872dd..5f5f07731 100644 --- a/tests/Fallout.Migrate.Specs/CsprojRewriterSpecs.cs +++ b/tests/Fallout.Migrate.Specs/CsprojRewriterSpecs.cs @@ -1,5 +1,6 @@ using FluentAssertions; using Xunit; +using Fallout.Migrate.Steps; namespace Fallout.Migrate.Specs; diff --git a/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs b/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs index 67f71f06d..4430a5fb1 100644 --- a/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs +++ b/tests/Fallout.Migrate.Specs/MigrationIntegrationSpecs.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Text; +using System.Threading.Tasks; using FluentAssertions; using Xunit; @@ -9,14 +10,14 @@ namespace Fallout.Migrate.Specs; public class MigrationIntegrationSpecs { [Fact] - public void MigratesVanillaConsumerRepo() + public async Task MigratesVanillaConsumerRepo() { var temp = CreateVanillaFixture(); try { var migration = new Migration(temp, dryRun: false, TextWriter.Null); - var summary = migration.Run(); + var summary = await migration.RunAsync(); // Build file rewritten end to end. var buildCsproj = File.ReadAllText(Path.Combine(temp, "build", "_build.csproj")); @@ -53,7 +54,7 @@ public void MigratesVanillaConsumerRepo() } [Fact] - public void DryRunDoesNotWriteFiles() + public async Task DryRunDoesNotWriteFiles() { var temp = CreateVanillaFixture(); @@ -62,7 +63,7 @@ public void DryRunDoesNotWriteFiles() var beforeCsproj = File.ReadAllText(Path.Combine(temp, "build", "_build.csproj")); var beforeNukeDir = Directory.Exists(Path.Combine(temp, ".nuke")); - var summary = new Migration(temp, dryRun: true, TextWriter.Null).Run(); + var summary = await new Migration(temp, dryRun: true, TextWriter.Null).RunAsync(); File.ReadAllText(Path.Combine(temp, "build", "_build.csproj")).Should().Be(beforeCsproj); Directory.Exists(Path.Combine(temp, ".nuke")).Should().Be(beforeNukeDir); @@ -75,14 +76,14 @@ public void DryRunDoesNotWriteFiles() } [Fact] - public void WarnsWhenBothNukeAndFalloutDirectoriesExist() + public async Task WarnsWhenBothNukeAndFalloutDirectoriesExist() { var temp = CreateVanillaFixture(); Directory.CreateDirectory(Path.Combine(temp, ".fallout")); try { - var summary = new Migration(temp, dryRun: false, TextWriter.Null).Run(); + var summary = await new Migration(temp, dryRun: false, TextWriter.Null).RunAsync(); summary.Warnings.Should().Contain(w => w.Contains(".nuke/") && w.Contains(".fallout/")); summary.DirectoriesRenamed.Should().Be(0); diff --git a/tests/Fallout.Migrate.Specs/ScriptRewriterSpecs.cs b/tests/Fallout.Migrate.Specs/ScriptRewriterSpecs.cs index 9ead9dc3f..f9f4956a2 100644 --- a/tests/Fallout.Migrate.Specs/ScriptRewriterSpecs.cs +++ b/tests/Fallout.Migrate.Specs/ScriptRewriterSpecs.cs @@ -1,5 +1,6 @@ using FluentAssertions; using Xunit; +using Fallout.Migrate.Steps; namespace Fallout.Migrate.Specs;