diff --git a/.agents/skills/adding-a-migration-step/SKILL.md b/.agents/skills/adding-a-migration-step/SKILL.md index 85a2e7307..a8f14eb9a 100644 --- a/.agents/skills/adding-a-migration-step/SKILL.md +++ b/.agents/skills/adding-a-migration-step/SKILL.md @@ -6,12 +6,12 @@ description: How to make fallout-migrate handle a new rename, removal, or rewrit `fallout-migrate` runs a fixed, ordered list of `IMigrationStep` implementations, built in `src/Fallout.Migrate/Migration.cs`. A **step is one operation over one set of files**, and it owns the rewrite rules for those files. -`RewriteCsprojsStep` holds the rules for `*.csproj`; `RewriteCsFilesStep` holds +`RewriteCsprojsStep` holds the rules for `*.csproj` and `*.props`; `RewriteCsFilesStep` holds the rules for `*.cs`. ## Adding a new rename, removal, or rewrite -1. Find the step for that file type — `RewriteCsprojsStep` for `*.csproj`, +1. Find the step for that file type — `RewriteCsprojsStep` for `*.csproj` and `*.props`, `RewriteCsFilesStep` for `*.cs`, `RewriteBootstrapScriptsStep` for the bootstrap scripts. 2. Add a `private static readonly Regex` field to that step, with a comment diff --git a/docs/Migration/from-nuke.md b/docs/Migration/from-nuke.md index 934bb0bf4..0289df2ab 100644 --- a/docs/Migration/from-nuke.md +++ b/docs/Migration/from-nuke.md @@ -15,7 +15,7 @@ Done. Read on if anything looks unusual, or you want to know what the tool did. ## The recommended path: `fallout-migrate` -`fallout-migrate` is a global `dotnet` tool that performs the entire migration in one command. It rewrites `PackageReference`s, `using` directives, MSBuild properties, bootstrap scripts, env vars, and renames `.nuke/` → `.fallout/`. It's idempotent and has a `--dry-run` mode. +`fallout-migrate` is a global `dotnet` tool that performs the entire migration in one command. It rewrites `PackageReference`s and central package versions (`Directory.Packages.props`), `using` directives, MSBuild properties, bootstrap scripts, env vars, and renames `.nuke/` → `.fallout/`. It's idempotent and has a `--dry-run` mode. ### Step 1 — install the tool diff --git a/src/Fallout.Migrate/Common/MigrationFileOperations.cs b/src/Fallout.Migrate/Common/MigrationFileOperations.cs index a5ba5e44b..3311b7159 100644 --- a/src/Fallout.Migrate/Common/MigrationFileOperations.cs +++ b/src/Fallout.Migrate/Common/MigrationFileOperations.cs @@ -50,7 +50,7 @@ public static void ApplyRewrite( { original = path.ReadAllText(); } - catch (IOException ex) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { summary.Warnings.Add($"could not read {RelativePath(context.RootDirectory, path)}: {ex.Message}"); return; diff --git a/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs b/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs index c69d15e77..6be7c5ab5 100644 --- a/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs +++ b/src/Fallout.Migrate/Steps/RewriteCsprojsStep.cs @@ -1,48 +1,54 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using Fallout.Common.IO; using Fallout.Migrate.Common; namespace Fallout.Migrate.Steps; /// -/// Rewrites every *.csproj file under the repository root: 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. +/// Rewrites every *.csproj and *.props file under the repository root: Nuke.* +/// package/project references and central PackageVersion items become Fallout.* +/// (pinning the current Fallout version where a Version or VersionOverride attribute +/// was present), Nuke* MSBuild properties are renamed to Fallout*, version properties +/// are bumped wherever they are defined, and stale explicit +/// System.Security.Cryptography.Xml pins are stripped from *.csproj files. /// internal sealed class RewriteCsprojsStep : IMigrationStep { - // Combined rewrite: Nuke.X PackageReference WITH an inline Version attribute → Fallout.X - // at the current Fallout version. NUKE-era pins (e.g. `Version="10.1.0"`) don't exist as - // Fallout.* packages and produce NU1603 ("not found, falling back to next-higher") which - // `WarningsAsErrors` in the migrated project escalates. Bumping in the same pass avoids - // a broken post-migrate build (#217). Tolerates extra attributes between Include and Version - // (e.g. `PrivateAssets="all"`). - // We don't mach MSBuild variables (`$(...)`) here, because they are handled below - private static readonly Regex nukePackageWithInlineVersionPattern = new( - @"(?[A-Z][A-Za-z0-9.]+)(?""[^>]*?\s+Version="")(?!\$\()[^""]+", + // Package items: PackageReference, central PackageVersion (Directory.Packages.props, #492) + // and PackageDownload. A Nuke.X item becomes Fallout.X, and a literal Version or + // VersionOverride on it is pinned to the current Fallout version, whatever the attribute + // order. NUKE-era pins (e.g. `Version="10.1.0"`) don't exist as Fallout.* packages and + // produce NU1603 ("not found, falling back to next-higher") which `WarningsAsErrors` in the + // migrated project escalates. Pinning in the same pass avoids a broken post-migrate build + // (#217). MSBuild variables (`$(...)`) are handled by HandleMsBuildVariable below. + private static readonly Regex packageItemPattern = new( + @"<(?:PackageReference|PackageVersion|PackageDownload)\b[^>]*>", RegexOptions.Compiled); - // PackageReference / ProjectReference `Include="Nuke.X"` → `Include="Fallout.X"` — namespace - // only. Catches references that DON'T have an inline Version (central package management). - // Must run AFTER NukePackageWithInlineVersionPattern so it only touches what's left. - private static readonly Regex packageReferencePattern = + // The `Nuke.` prefix of an Include, Update or Remove value: `Include="Nuke.X"` → `Include="Fallout.X"`. + // Pass 1 applies it to package items. Pass 2 applies it to every item that pass 1 didn't touch. + private static readonly Regex nukeItemNamePattern = new(@"(?<=\b(?:Include|Update|Remove)="")Nuke\.(?=[A-Z])", RegexOptions.Compiled); - // Detects MSBuild variables used by already-rewritten Fallout.* PackageReferences: - // Version="$(MyVar)". Scoped to Fallout.* so variables shared with unrelated packages - // aren't mistaken for Fallout version variables. - private static readonly Regex falloutPackageReferenceVariablePattern = new( - @"]*?Version=""\$\((?[^)]+)\)""", + // The literal value of a Version or VersionOverride attribute. PackageDownload needs an exact + // range (`[10.1.0]`), so the brackets stay outside the match. A version range such as + // `[10.1.0,)` or `(10.1.0,11.0.0)` doesn't match and is kept as written: replacing only + // one bound would leave an invalid range. + private static readonly Regex literalVersionPattern = new( + @"(?<=\bVersion(?:Override)?=""\[?)(?!\$\()[^""\[\],()]+(?=\]?"")", RegexOptions.Compiled); - // Same variable-usage detection, but for PackageReferences that are NOT Fallout.* — used to - // detect a variable ambiguously shared between a Fallout package and an unrelated one. - private static readonly Regex nonFalloutPackageReferenceVariablePattern = new( - @"]*?Version=""\$\((?[^)]+)\)""", + // A PackageReference or central PackageVersion whose version is an MSBuild variable: + // Version="$(MyVar)". The Include and Version attributes can come in either order. + // Used to classify the variables (both Nuke.* and Fallout.* spellings count, because the files + // are classified before they are rewritten) and to redirect an ambiguous one. + private static readonly Regex variableVersionItemPattern = new( + @"<(?:PackageReference|PackageVersion)\b(?=[^>]*\bInclude=""(?[^""]+)"")(?=[^>]*\bVersion=""\$\((?[^)]+)\)"")[^>]*>", RegexOptions.Compiled); // MSBuild element/property names that begin with `Nuke` followed by an uppercase @@ -70,7 +76,8 @@ internal sealed class RewriteCsprojsStep : IMigrationStep // requires a newer version (10.0.6+) and the conflict trips NU1605 ("Detected package // downgrade"). Removing the explicit pin lets the transitive version win, which is what the // migrated project wants (#217). Matches a self-closing element with optional surrounding - // indentation + trailing newline. + // indentation + trailing newline. Applied to *.csproj only: a pin in a *.props file such as + // a root Directory.Build.props applies to every project in the repository, not just the build. private static readonly Regex cryptographyXmlPackageRefPattern = new( @"^[ \t]*[ \t]*\r?\n?", RegexOptions.Compiled | RegexOptions.Multiline); @@ -78,43 +85,76 @@ internal sealed class RewriteCsprojsStep : IMigrationStep /// public Task ExecuteAsync(MigrationContext context, Summary summary) { - foreach (var path in MigrationFileOperations.EnumerateFiles(context.RootDirectory, "*.csproj")) + var files = MigrationFileOperations.EnumerateFiles(context.RootDirectory, "*.csproj") + .Concat(MigrationFileOperations.EnumerateFiles(context.RootDirectory, "*.props")) + .ToList(); + + // A version variable can be used in one file and defined in another, for example + // `$(NukeVersion)` in Directory.Packages.props and `` in Version.props (#492). + // So the variables are classified over all files first, then every file is rewritten. + var variables = VersionVariables.Collect(files.Select(ReadOrEmpty)); + + foreach (var path in files) { MigrationFileOperations.ApplyRewrite( context, path, - content => Rewrite(content, context.FalloutVersion), + content => Rewrite( + content, + context.FalloutVersion, + variables, + isProjectFile: path.ToString().EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)), summary); } return Task.CompletedTask; + + static string ReadOrEmpty(AbsolutePath path) + { + // An unreadable file is reported by ApplyRewrite; it contributes nothing here. + try + { + return path.ReadAllText(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return string.Empty; + } + } } /// /// 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 original .csproj or .props file content. + /// The Fallout version to pin into rewritten versioned references. + /// The version variables classified over all files. + /// true for a .csproj, false for a .props file. /// The rewritten content and the number of edits made. - private static RewriteResult Rewrite(string original, string falloutVersion) + private static RewriteResult Rewrite(string original, string falloutVersion, VersionVariables variables, bool isProjectFile) { var edits = 0; var content = original; - // Pass 1 — combined Include + Version rewrite for Nuke.X PackageReferences with inline Version. - content = nukePackageWithInlineVersionPattern.Replace(content, m => + // Pass 1 — package items: rename a Nuke.X Include and pin its literal Version/VersionOverride. + // One edit per item, however many attributes changed. An item that is already Fallout.X + // keeps its pin. + content = packageItemPattern.Replace(content, m => { + var item = nukeItemNamePattern.Replace(m.Value, "Fallout."); + if (item == m.Value) + { + return m.Value; + } + edits++; - return m.Groups["prefix"].Value - + "Fallout." + m.Groups["name"].Value - + m.Groups["between"].Value - + falloutVersion; + return literalVersionPattern.Replace(item, _ => falloutVersion); }); - // Pass 2 — namespace-only rewrites for anything Pass 1 didn't consume (CPM-managed - // PackageReferences without inline Version, ProjectReferences, MSBuild properties). - content = packageReferencePattern.Replace(content, _ => + // Pass 2 — namespace-only rewrites for anything Pass 1 didn't consume (other item types, + // MSBuild properties). + content = nukeItemNamePattern.Replace(content, _ => { edits++; return "Fallout."; @@ -133,64 +173,92 @@ private static RewriteResult Rewrite(string original, string falloutVersion) return string.Empty; }); - // Pass 4 — strip the stale System.Security.Cryptography.Xml direct pin. - content = cryptographyXmlPackageRefPattern.Replace(content, _ => + // Pass 4 — strip the stale System.Security.Cryptography.Xml direct pin (project files only). + if (isProjectFile) { - edits++; - return string.Empty; - }); + content = cryptographyXmlPackageRefPattern.Replace(content, _ => + { + edits++; + return string.Empty; + }); + } - return HandleMsBuildVariable(falloutVersion, content, edits); + return HandleMsBuildVariable(falloutVersion, content, edits, variables); } - // Pass 5 — extract variables used by Fallout.* PackageReferences, decouple the ones ambiguously - // shared with non-Fallout packages via a dedicated $(FalloutVersion) property, and bump every - // variable that's now exclusively Fallout's to the current Fallout version. - private static RewriteResult HandleMsBuildVariable(string falloutVersion, string content, int edits) + // Pass 5 — decouple the variables ambiguously shared with non-Fallout packages via a dedicated + // $(FalloutVersion) property, and bump every variable that's exclusively Fallout's to the + // current Fallout version, in whichever file defines it. + private static RewriteResult HandleMsBuildVariable(string falloutVersion, string content, int edits, VersionVariables variables) { - const string falloutVersionVariable = "FalloutVersion"; - - var (variablesToBump, ambiguousVariables) = - ClassifyPackageReferenceVariables(content, falloutVersionVariable); - (content, int redirectEdits) = - RedirectAmbiguousVariablesToFalloutVersion(content, ambiguousVariables, falloutVersionVariable); + RedirectAmbiguousVariablesToFalloutVersion(content, variables.Ambiguous, VersionVariables.FalloutVersionVariable); edits += redirectEdits; - content = EnsureFalloutVersionPropertyExists(content, ambiguousVariables, falloutVersionVariable, falloutVersion, - ref edits); + // Only a file with a redirected reference gets the property. Every other file stays unchanged. + if (redirectEdits > 0 && !variables.DefinesFalloutVersion) + { + content = EnsureFalloutVersionPropertyExists(content, VersionVariables.FalloutVersionVariable, falloutVersion, ref edits); + } - (content, int bumpEdits) = BumpVariableProperties(content, variablesToBump, falloutVersion); + (content, int bumpEdits) = BumpVariableProperties(content, variables.ToBump, falloutVersion); edits += bumpEdits; return new RewriteResult(content, edits); } - // A variable also shared with a non-Fallout package is ambiguous: bumping it directly would - // change that unrelated package's version too, so it's decoupled instead — the Fallout - // reference is redirected to a dedicated $(FalloutVersion) property. - private static (HashSet variablesToBump, HashSet ambiguousVariables) ClassifyPackageReferenceVariables( - string content, string falloutVersionVariable) + /// + /// The version variables found in the files of one migration run: those used only by Nuke.*/Fallout.* + /// packages (bumped), those also shared with an unrelated package (ambiguous), and whether any + /// file already defines the dedicated FalloutVersion property. + /// + private sealed class VersionVariables { - var nonFalloutVariables = nonFalloutPackageReferenceVariablePattern.Matches(content) - .Select(m => m.Groups["variable"].Value) - .ToHashSet(); - - var variablesToBump = new HashSet + public const string FalloutVersionVariable = "FalloutVersion"; + + // The property is renamed from NukeVersion by msBuildPropertyPattern, so both spellings count. + private static readonly Regex falloutVersionPropertyPattern = + new(@"<(?:Nuke|Fallout)Version\s*>", RegexOptions.Compiled); + + public HashSet ToBump { get; } = [FalloutVersionVariable]; + public HashSet Ambiguous { get; } = []; + public bool DefinesFalloutVersion { get; private set; } + + // A variable also shared with a non-Fallout package is ambiguous: bumping it directly would + // change that unrelated package's version too, so it's decoupled instead — the Fallout + // reference is redirected to a dedicated $(FalloutVersion) property. + // + // Ambiguity is decided by variable name over all files. A per-project property used by a + // Fallout package in project A and by an unrelated package in project B is ambiguous, so + // A is decoupled even though A's own definition isn't shared. The result is still correct, + // it only has more edits than needed. + public static VersionVariables Collect(IEnumerable contents) { - falloutVersionVariable - }; + var result = new VersionVariables(); + var falloutVariables = new HashSet(); + var nonFalloutVariables = new HashSet(); - var ambiguousVariables = new HashSet(); + foreach (var content in contents) + { + foreach (Match match in variableVersionItemPattern.Matches(content)) + { + var include = match.Groups["include"].Value; + var isFalloutPackage = include.StartsWith("Nuke.", StringComparison.Ordinal) || + include.StartsWith("Fallout.", StringComparison.Ordinal); + (isFalloutPackage ? falloutVariables : nonFalloutVariables).Add(match.Groups["variable"].Value); + } - foreach (var variable in falloutPackageReferenceVariablePattern.Matches(content) - .Select(m => m.Groups["variable"].Value)) - { - (nonFalloutVariables.Contains(variable) ? ambiguousVariables : variablesToBump).Add(variable); - } + result.DefinesFalloutVersion |= falloutVersionPropertyPattern.IsMatch(content); + } + + foreach (var variable in falloutVariables) + { + (nonFalloutVariables.Contains(variable) ? result.Ambiguous : result.ToBump).Add(variable); + } - return (variablesToBump, ambiguousVariables); + return result; + } } private static (string content, int edits) RedirectAmbiguousVariablesToFalloutVersion( @@ -198,28 +266,29 @@ private static (string content, int edits) RedirectAmbiguousVariablesToFalloutVe { var edits = 0; - foreach (var variable in ambiguousVariables) + // Runs after the rename, so only Fallout.* items are redirected. Only the variable name + // inside `$(...)` is swapped. The rest of the item stays as written. + content = variableVersionItemPattern.Replace(content, m => { - // Matches `Version="$(variable)"` on a Fallout.* PackageReference only, capturing - // everything up to and including the opening `Version="` so it can be re-emitted - // unchanged while just swapping the variable reference. - var redirectPattern = new Regex( - $@"(]*?Version="")\$\({Regex.Escape(variable)}\)"); - - content = redirectPattern.Replace(content, m => + var variable = m.Groups["variable"]; + if (!m.Groups["include"].Value.StartsWith("Fallout.", StringComparison.Ordinal) || + !ambiguousVariables.Contains(variable.Value)) { - edits++; - return m.Groups[1].Value + $"$({falloutVersionVariable})"; - }); - } + return m.Value; + } + + edits++; + var offset = variable.Index - m.Index; + return m.Value.Remove(offset, variable.Length).Insert(offset, falloutVersionVariable); + }); return (content, edits); } private static string EnsureFalloutVersionPropertyExists( - string content, HashSet ambiguousVariables, string falloutVersionVariable, string falloutVersion, ref int edits) + string content, string falloutVersionVariable, string falloutVersion, ref int edits) { - if (ambiguousVariables.Count == 0 || content.Contains($"<{falloutVersionVariable}>")) + if (content.Contains($"<{falloutVersionVariable}>")) { return content; } diff --git a/tests/Fallout.Migrate.Specs/RewriteCsprojsStepSpecs.cs b/tests/Fallout.Migrate.Specs/RewriteCsprojsStepSpecs.cs index fe1fa19a0..44bdade00 100644 --- a/tests/Fallout.Migrate.Specs/RewriteCsprojsStepSpecs.cs +++ b/tests/Fallout.Migrate.Specs/RewriteCsprojsStepSpecs.cs @@ -220,6 +220,30 @@ public async Task Conflicting_system_security_cryptography_xml_pin_is_stripped() buildCsproj.Should().Contain(@"Include=""Fallout.Common"""); } + [Fact] + public async Task Cryptography_xml_pin_in_a_props_file_is_kept() + { + // A pin in a root Directory.Build.props applies to every project in the repository, + // so only the pin in a project file is stripped. + // Arrange + const string input = """ + + + + + + """; + + (tempDirectory / "Directory.Build.props").WriteAllText(input, eofLineBreak: false); + + // Act + await new RewriteCsprojsStep().ExecuteAsync(context, summary); + + // Assert + summary.EditCount.Should().Be(0); + (tempDirectory / "Directory.Build.props").ReadAllText().Should().Be(input); + } + [Fact] public async Task Other_system_packages_are_left_alone() { @@ -480,4 +504,311 @@ public async Task Decouples_ambiguously_used_variables_when_a_property_group_but .And.Contain("$(FalloutVersion)", Exactly.Once()) .And.Contain("$(PkgVersion)"); } + + [Fact] + public async Task Central_package_versions_are_renamed_and_pinned() + { + // Arrange + (tempDirectory / "Directory.Packages.props").WriteAllText(""" + + + + + + + + """, eofLineBreak: false); + (tempDirectory / "build" / "_build.csproj").WriteAllText(""" + + + + + + """, eofLineBreak: false); + + // Act + await new RewriteCsprojsStep().ExecuteAsync(context, summary); + + // Assert + summary.EditCount.Should().Be(3); + (tempDirectory / "Directory.Packages.props").ReadAllText().Should().Be(""" + + + + + + + + """); + (tempDirectory / "build" / "_build.csproj").ReadAllText().Should().Contain(""""""); + } + + [Fact] + public async Task Version_property_in_an_imported_props_file_is_renamed_and_bumped() + { + // Arrange + (tempDirectory / "Directory.Packages.props").WriteAllText(""" + + + + + + + """, eofLineBreak: false); + (tempDirectory / "Version.props").WriteAllText(""" + + + 10.1.0 + + + """, eofLineBreak: false); + + // Act + await new RewriteCsprojsStep().ExecuteAsync(context, summary); + + // Assert + (tempDirectory / "Directory.Packages.props").ReadAllText() + .Should().Contain(""""""); + (tempDirectory / "Version.props").ReadAllText().Should().Contain("11.0.0"); + } + + [Fact] + public async Task Version_variable_defined_in_another_file_is_bumped() + { + // Arrange + (tempDirectory / "Directory.Packages.props").WriteAllText(""" + + + + + + """, eofLineBreak: false); + (tempDirectory / "Version.props").WriteAllText(""" + + + 10.1.0 + + + """, eofLineBreak: false); + + // Act + await new RewriteCsprojsStep().ExecuteAsync(context, summary); + + // Assert + (tempDirectory / "Version.props").ReadAllText().Should().Contain("11.0.0"); + } + + [Fact] + public async Task Version_variable_shared_with_another_package_in_another_file_is_decoupled() + { + // Arrange + (tempDirectory / "Directory.Packages.props").WriteAllText(""" + + + true + + + + + + """, eofLineBreak: false); + (tempDirectory / "build" / "_build.csproj").WriteAllText(""" + + + + + + """, eofLineBreak: false); + (tempDirectory / "Version.props").WriteAllText(""" + + + 4.0.0 + + + """, eofLineBreak: false); + + // Act + await new RewriteCsprojsStep().ExecuteAsync(context, summary); + + // Assert + var packages = (tempDirectory / "Directory.Packages.props").ReadAllText(); + packages.Should().Contain(""""""); + packages.Should().Contain("11.0.0"); + (tempDirectory / "Version.props").ReadAllText().Should().Contain("4.0.0"); + } + + [Fact] + public async Task Files_without_a_redirected_reference_are_unchanged_when_a_variable_is_decoupled() + { + // Arrange + (tempDirectory / "Directory.Packages.props").WriteAllText(""" + + + true + + + + + + + """, eofLineBreak: false); + const string versionProps = """ + + + 4.0.0 + + + """; + (tempDirectory / "Version.props").WriteAllText(versionProps, eofLineBreak: false); + const string libraryProject = """ + + + net10.0 + + + """; + (tempDirectory / "src" / "Lib.csproj").WriteAllText(libraryProject, eofLineBreak: false); + + // Act + await new RewriteCsprojsStep().ExecuteAsync(context, summary); + + // Assert + var packages = (tempDirectory / "Directory.Packages.props").ReadAllText(); + packages.Should().Contain(""""""); + packages.Should().Contain("11.0.0"); + (tempDirectory / "Version.props").ReadAllText().Should().Be(versionProps); + (tempDirectory / "src" / "Lib.csproj").ReadAllText().Should().Be(libraryProject); + } + + [Fact] + public async Task Version_variable_before_include_is_decoupled() + { + // Arrange + (tempDirectory / "Directory.Packages.props").WriteAllText(""" + + + 4.0.0 + + + + + + + """, eofLineBreak: false); + + // Act + await new RewriteCsprojsStep().ExecuteAsync(context, summary); + + // Assert + var packages = (tempDirectory / "Directory.Packages.props").ReadAllText(); + packages.Should().Contain(""""""); + packages.Should().Contain(""""""); + packages.Should().Contain("4.0.0"); + packages.Should().Contain("11.0.0"); + } + + [Fact] + public async Task Version_range_is_kept_as_written() + { + // Arrange + (tempDirectory / "build" / "_build.csproj").WriteAllText(""" + + + + + + + """, eofLineBreak: false); + + // Act + await new RewriteCsprojsStep().ExecuteAsync(context, summary); + + // Assert + var buildCsproj = (tempDirectory / "build" / "_build.csproj").ReadAllText(); + buildCsproj.Should().Contain(""""""); + buildCsproj.Should().Contain(""""""); + } + + [Fact] + public async Task Stale_version_override_is_pinned_to_the_current_fallout_version() + { + // Arrange + (tempDirectory / "build" / "_build.csproj").WriteAllText(""" + + + + + + """, eofLineBreak: false); + + // Act + await new RewriteCsprojsStep().ExecuteAsync(context, summary); + + // Assert + summary.EditCount.Should().Be(1); + (tempDirectory / "build" / "_build.csproj").ReadAllText() + .Should().Contain(""""""); + } + + [Fact] + public async Task Version_attribute_before_include_is_also_pinned() + { + // Arrange + (tempDirectory / "build" / "_build.csproj").WriteAllText(""" + + + + + + """, eofLineBreak: false); + + // Act + await new RewriteCsprojsStep().ExecuteAsync(context, summary); + + // Assert + summary.EditCount.Should().Be(1); + (tempDirectory / "build" / "_build.csproj").ReadAllText() + .Should().Contain(""""""); + } + + [Fact] + public async Task Package_download_keeps_its_exact_version_brackets() + { + // Arrange + (tempDirectory / "build" / "_build.csproj").WriteAllText(""" + + + + + + """, eofLineBreak: false); + + // Act + await new RewriteCsprojsStep().ExecuteAsync(context, summary); + + // Assert + (tempDirectory / "build" / "_build.csproj").ReadAllText() + .Should().Contain(""""""); + } + + [Fact] + public async Task Already_migrated_package_pins_are_kept() + { + // Arrange + const string input = """ + + + + + + """; + (tempDirectory / "Directory.Packages.props").WriteAllText(input, eofLineBreak: false); + + // Act + await new RewriteCsprojsStep().ExecuteAsync(context, summary); + + // Assert + summary.EditCount.Should().Be(0); + (tempDirectory / "Directory.Packages.props").ReadAllText().Should().Be(input); + } }