diff --git a/.centralconfig.json b/.centralconfig.json new file mode 100644 index 0000000..779ceec --- /dev/null +++ b/.centralconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://raw.githubusercontent.com/TarasKovalenko/CentralConfigGenerator/main/schemas/centralconfig.schema.json", + "conflictStrategy": "Highest", + "backup": true, + "addGitignore": true, + "failOn": "High", + "retention": { "enabled": true, "maxBackups": 5 }, + "rules": { + "OutdatedPackage": "Low", + "PropertyDrift": "none" + }, + "buildProperties": [ + "ImplicitUsings", + "Nullable", + "LangVersion", + "InvariantGlobalization" + ] +} diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 7fcd6dc..29b4771 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,28 +1,111 @@ -# This workflow will build a .NET project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net - name: .NET on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] + +permissions: + contents: read jobs: build: + name: Build & test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} + restore-keys: ${{ runner.os }}-nuget- + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Test + run: dotnet test --no-build --configuration Release --verbosity normal + + self-check: + name: Dependency health (dogfood) runs-on: ubuntu-latest + needs: build + permissions: + contents: read + security-events: write steps: - - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.0.x - - name: Restore dependencies - run: dotnet restore - - name: Build - run: dotnet build --no-restore - - name: Test - run: dotnet test --no-build --verbosity normal + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Build the tool + run: dotnet build CentralConfigGenerator/CentralConfigGenerator.csproj -f net10.0 -c Release + + # The tool analyses its own repository, so a regression in the analyzers shows up here first. + - name: Analyse dependencies + run: | + dotnet run --project CentralConfigGenerator/CentralConfigGenerator.csproj -f net10.0 -c Release --no-build -- \ + analyze --audit --deprecated --licenses \ + --output Sarif --output-file centralconfig.sarif \ + --fail-on Critical --quiet + + - name: Publish findings + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: centralconfig.sarif + category: centralconfig + + - name: Job summary + if: always() + run: | + dotnet run --project CentralConfigGenerator/CentralConfigGenerator.csproj -f net10.0 -c Release --no-build -- \ + analyze --audit --deprecated --output Markdown --fail-on Never --quiet >> "$GITHUB_STEP_SUMMARY" + + pack: + name: Pack + runs-on: ubuntu-latest + needs: build + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Pack + run: dotnet pack CentralConfigGenerator/CentralConfigGenerator.csproj -c Release -o ./artifacts + + - name: Upload package + uses: actions/upload-artifact@v4 + with: + name: nupkg + path: ./artifacts/*.nupkg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..778d535 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,87 @@ +name: release + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + version: + description: 'Version to publish, without the leading v (e.g. 1.0.0)' + required: true + type: string + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-latest + env: + CI: true + DOTNET_NOLOGO: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # The tool multi-targets net8.0/net9.0/net10.0, so every SDK has to be present. + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Resolve version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + version="${{ inputs.version }}" + else + version="${GITHUB_REF_NAME#v}" + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Publishing $version" + + - name: Restore + run: dotnet restore CentralConfigGenerator.sln + + - name: Build + run: dotnet build CentralConfigGenerator.sln -c Release --no-restore -p:Version=${{ steps.version.outputs.version }} + + - name: Test + run: dotnet test CentralConfigGenerator.sln -c Release --no-build + + - name: Pack + run: | + dotnet pack CentralConfigGenerator/CentralConfigGenerator.csproj \ + -c Release --no-build \ + -p:Version=${{ steps.version.outputs.version }} \ + -o artifacts/packages + + - uses: actions/upload-artifact@v4 + with: + name: packages-${{ steps.version.outputs.version }} + path: artifacts/packages/*.*nupkg + + # --skip-duplicate keeps a re-run from failing when a package version is already live. + - name: Push to NuGet + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "$NUGET_API_KEY" ]; then + echo "::error::NUGET_API_KEY is not set. Add it with: gh secret set NUGET_API_KEY" + exit 1 + fi + dotnet nuget push "artifacts/packages/*.nupkg" \ + --api-key "$NUGET_API_KEY" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate + + - name: Create GitHub release + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "${GITHUB_REF_NAME}" artifacts/packages/*.nupkg --generate-notes diff --git a/.gitignore b/.gitignore index 48a1780..45b7508 100644 --- a/.gitignore +++ b/.gitignore @@ -399,4 +399,7 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml -.idea/ \ No newline at end of file +.idea/ + +# CentralConfigGenerator backups +.centralconfig-backups/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..da29921 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,58 @@ +# Changelog + +## 2.0.0 + +A rewrite around one idea: migrating to Central Package Management should be provable, reversible, and only the beginning of keeping dependencies healthy. + +### New commands + +- `verify` — migrates, then diffs the resolved package graph before and after to prove NuGet still resolves identical packages. Rolls back automatically on drift (exit code `9`). +- `analyze` — 16 dependency-health rules with a 0–100 score, severity overrides, baselines, and `--fix` / `--fix-dry-run` auto-fixes. +- `update` — updates central versions, runs the tests, and rolls back on failure. `--bisect` keeps the largest subset that still passes and names what it held back. +- `revert` — undoes CPM, writing versions back into project files. +- `doctor` — environment diagnostics: SDK, solution layout, feed reachability, config, backups, `.gitignore`. +- `status` — workspace dashboard plus the single most useful next action. +- `tree` — resolved dependency graph as an ASCII tree. +- `batch` — runs the migration across every solution in a monorepo, optionally in parallel. +- `init` — scaffolds `.centralconfig.json` (JSON schema published under `schemas/`). +- `explain` — documents any analysis rule. +- `backups list | restore | prune` — inspect and roll back any change the tool made. +- `completions` — bash, zsh, fish and PowerShell completion scripts. + +### Migration improvements + +- **Format-preserving edits.** Encoding, BOM, line endings, comments, indentation and attribute order all survive; only what must change changes. `--encoding` and `--linewrap` override when you want normalisation instead. +- **Dry-run with unified diffs** (`--dry-run --diff`), planned entirely in memory so the preview and the write share one code path. +- **Backups before every write**, with restore, retention and pruning. Files the operation created are recorded too, so restoring removes them. +- `--merge` into an existing `Directory.Packages.props` without disturbing its comments or layout. +- Conflict strategies `Highest`, `Lowest`, `MostCommon`, `Fail`, plus `--interactive-conflicts`. +- `--transitive-pinning`, `--ignore-prerelease`, `--version-comparison`, `--keep-attrs`. +- F# and VB projects alongside C#; `.sln`, `.slnx` and `.slnf` scoping; `--exclude-dirs`. +- Versions declared as child elements (`1.0.0`) are handled, as are `GlobalPackageReference` and `VersionOverride`. + +### Directory.Build.props + +- Property hoisting now requires **unanimity**: a property moves up only when every project declaring it uses the same value. Conditioned `PropertyGroup`s and identity properties (`AssemblyName`, `RootNamespace`, …) are never touched. +- Configurable candidate list via `--property` or `buildProperties` in the config file. + +### Reporting and CI + +- Output as Terminal, JSON, SARIF 2.1.0, Markdown or CSV, to stdout or a file. +- Stable exit codes 0–9 so CI can branch on the outcome. +- `--fail-on`, `--rules Rule=Severity`, `--baseline` and `--write-baseline` for gradual adoption. + +### Platform + +- Multi-targets `net8.0`, `net9.0` and `net10.0`. +- Private feeds honoured through `nuget.config`. +- 320+ tests. + +### Breaking changes + +- `packages-enhanced` is now an alias for `migrate`; the enhanced analysis is the default path. +- The CLI moved from `System.CommandLine` to `Spectre.Console.Cli`. Command names are unchanged and `packages` / `convert` alias `migrate`. +- `-d` is `--directory`; use `-n` or `--dry-run` for dry runs. + +## 1.1.1 and earlier + +See the git history. diff --git a/CentralConfigGenerator.Core.Tests/Analysis/AnalysisEngineTests.cs b/CentralConfigGenerator.Core.Tests/Analysis/AnalysisEngineTests.cs new file mode 100644 index 0000000..203b813 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Analysis/AnalysisEngineTests.cs @@ -0,0 +1,357 @@ +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.NuGet; +using CentralConfigGenerator.Core.Tests.TestSupport; + +namespace CentralConfigGenerator.Core.Tests.Analysis; + +public class AnalysisEngineTests +{ + private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-analysis"); + + [Fact] + public async Task AnalyzeAsync_ShouldReportVersionInconsistency() + { + var report = await RunAsync(WorkspaceWithTwoProjects()); + + report.Findings.ShouldContain(f => f.RuleId == RuleIds.VersionInconsistency); + report.Findings + .First(f => f.RuleId == RuleIds.VersionInconsistency && f.PackageId == "Newtonsoft.Json") + .FixValue.ShouldBe("13.0.3"); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReportCasingMismatches() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "a", "A.csproj"), Reference("Newtonsoft.Json", "13.0.3")) + .AddFile(Path.Combine(Root, "b", "B.csproj"), Reference("newtonsoft.json", "13.0.3")); + + var report = await RunAsync(fileSystem); + + report.Findings.ShouldContain(f => f.RuleId == RuleIds.DuplicatePackageCasing); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReportFloatingVersions() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "a", "A.csproj"), Reference("xunit", "2.9.*")); + + var report = await RunAsync(fileSystem); + + report.Findings.ShouldContain(f => f.RuleId == RuleIds.FloatingVersion); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReportDuplicateReferencesInOneProject() + { + var content = """ + + + + + + + """; + + var fileSystem = new InMemoryFileSystem().AddFile(Path.Combine(Root, "A.csproj"), content); + + var report = await RunAsync(fileSystem); + + report.Findings.ShouldContain(f => f.RuleId == RuleIds.RedundantReference); + } + + [Fact] + public async Task AnalyzeAsync_ShouldNotFlagConditionedReferencesAsDuplicates() + { + var content = """ + + + + + + + + + """; + + var fileSystem = new InMemoryFileSystem().AddFile(Path.Combine(Root, "A.csproj"), content); + + var report = await RunAsync(fileSystem); + + report.Findings.ShouldNotContain(f => f.RuleId == RuleIds.RedundantReference); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReportInlineVersionsUnderCentralManagement() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Reference("Serilog", "2.0.0")) + .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps); + + var report = await RunAsync(fileSystem); + + var finding = report.Findings.First(f => f.RuleId == RuleIds.InlineVersionUnderCpm); + finding.Severity.ShouldBe(Severity.High); + finding.Fix.ShouldBe(FixKind.RemoveInlineVersion); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReportOrphanedCentralEntries() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Reference("Serilog", null)) + .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps); + + var report = await RunAsync(fileSystem); + + report.Findings.ShouldContain(f => + f.RuleId == RuleIds.OrphanedPackageVersion && f.PackageId == "Newtonsoft.Json" + ); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReportPackagesWithNoVersionAtAll() + { + var props = """ + + + true + + + """; + + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Reference("Serilog", null)) + .AddFile(Path.Combine(Root, "Directory.Packages.props"), props); + + var report = await RunAsync(fileSystem); + + report.Findings.ShouldContain(f => f.RuleId == RuleIds.MissingCentralVersion); + } + + [Fact] + public async Task AnalyzeAsync_ShouldSkipNetworkRulesWhenTheyAreNotRequested() + { + var report = await RunAsync(WorkspaceWithTwoProjects()); + + report.Findings.ShouldNotContain(f => f.RuleId == RuleIds.OutdatedPackage); + report.Findings.ShouldNotContain(f => f.RuleId == RuleIds.SecurityVulnerability); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReportVulnerabilitiesWhenAuditingIsOn() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Reference("Vulnerable.Pkg", "1.0.0")); + + var feed = new FakeNuGetFeed().Add( + "Vulnerable.Pkg", + ["1.0.0", "1.0.1"], + vulnerabilities: [("1.0.0", Severity.Critical)] + ); + + var report = await RunAsync(fileSystem, feed, new AnalysisOptions { RootDirectory = Root, Audit = true }); + + var finding = report.Findings.First(f => f.RuleId == RuleIds.SecurityVulnerability); + finding.Severity.ShouldBe(Severity.Critical); + finding.FixValue.ShouldBe("1.0.1"); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReportDeprecatedPackages() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Reference("Old.Pkg", "1.0.0")); + + var feed = new FakeNuGetFeed().Add("Old.Pkg", ["1.0.0"], deprecated: true, alternate: "New.Pkg"); + + var report = await RunAsync( + fileSystem, + feed, + new AnalysisOptions { RootDirectory = Root, Deprecated = true } + ); + + report.Findings + .First(f => f.RuleId == RuleIds.DeprecatedPackage) + .Recommendation.ShouldNotBeNull() + .ShouldContain("New.Pkg"); + } + + [Fact] + public async Task AnalyzeAsync_ShouldGradeOutdatedPackagesByHowFarBehindTheyAre() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Reference("Old.Pkg", "1.0.0")); + + var feed = new FakeNuGetFeed().Add("Old.Pkg", ["1.0.0", "3.0.0"]); + + var report = await RunAsync( + fileSystem, + feed, + new AnalysisOptions { RootDirectory = Root, Outdated = true } + ); + + report.Findings.First(f => f.RuleId == RuleIds.OutdatedPackage).Severity + .ShouldBe(Severity.Moderate); + } + + [Fact] + public async Task AnalyzeAsync_ShouldFlagCopyleftLicences() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Reference("Gpl.Pkg", "1.0.0")); + + var feed = new FakeNuGetFeed().Add("Gpl.Pkg", ["1.0.0"], license: "GPL-3.0-only"); + + var report = await RunAsync( + fileSystem, + feed, + new AnalysisOptions { RootDirectory = Root, Licenses = true } + ); + + report.Findings.First(f => f.RuleId == RuleIds.LicenseRisk).Severity.ShouldBe(Severity.High); + } + + [Fact] + public async Task AnalyzeAsync_ShouldHonourRuleSeverityOverrides() + { + var options = new AnalysisOptions + { + RootDirectory = Root, + RuleOverrides = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [RuleIds.VersionInconsistency] = Severity.Critical, + }, + }; + + var report = await RunAsync(WorkspaceWithTwoProjects(), options: options); + + report.Findings + .First(f => f.RuleId == RuleIds.VersionInconsistency) + .Severity.ShouldBe(Severity.Critical); + } + + [Fact] + public async Task AnalyzeAsync_ShouldSkipDisabledRules() + { + var options = new AnalysisOptions + { + RootDirectory = Root, + RuleOverrides = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [RuleIds.VersionInconsistency] = null, + }, + }; + + var report = await RunAsync(WorkspaceWithTwoProjects(), options: options); + + report.Findings.ShouldNotContain(f => f.RuleId == RuleIds.VersionInconsistency); + } + + [Fact] + public async Task AnalyzeAsync_ShouldIgnoreListedPackages() + { + var options = new AnalysisOptions { RootDirectory = Root, IgnorePackages = ["Newtonsoft.*"] }; + + var report = await RunAsync(WorkspaceWithTwoProjects(), options: options); + + report.Findings.ShouldNotContain(f => f.PackageId == "Newtonsoft.Json"); + } + + [Fact] + public async Task AnalyzeAsync_ShouldScoreACleanWorkspaceAt100() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Reference("Serilog", "3.1.1")); + + var report = await RunAsync(fileSystem); + + report.Findings.ShouldBeEmpty(); + report.HealthScore.ShouldBe(100); + report.Grade.ShouldBe("A"); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReturnAnEmptyReportWhenThereAreNoProjects() + { + var report = await RunAsync(new InMemoryFileSystem().AddDirectory(Root)); + + report.ProjectCount.ShouldBe(0); + report.Findings.ShouldBeEmpty(); + } + + [Fact] + public async Task AnalyzeAsync_ShouldReportTransitiveDivergence() + { + var graph = new PackageGraph + { + IsAvailable = true, + Packages = + [ + new ResolvedPackage + { + PackageId = "System.Text.Json", + ResolvedVersion = "8.0.0", + ProjectPath = "A.csproj", + TargetFramework = "net8.0", + IsTransitive = true, + }, + new ResolvedPackage + { + PackageId = "System.Text.Json", + ResolvedVersion = "9.0.0", + ProjectPath = "B.csproj", + TargetFramework = "net8.0", + IsTransitive = true, + }, + ], + }; + + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Reference("Serilog", "3.1.1")); + + var engine = CreateEngine(fileSystem, new FakeNuGetFeed(), graph); + var report = await engine.AnalyzeAsync( + new AnalysisOptions { RootDirectory = Root, Transitive = true } + ); + + report.Findings.ShouldContain(f => f.RuleId == RuleIds.TransitiveConflict); + } + + private static InMemoryFileSystem WorkspaceWithTwoProjects() => + new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "a", "A.csproj"), Reference("Newtonsoft.Json", "13.0.1")) + .AddFile(Path.Combine(Root, "b", "B.csproj"), Reference("Newtonsoft.Json", "13.0.3")); + + private static string Reference(string packageId, string? version) => + $""" + + + + + + """; + + private static AnalysisEngine CreateEngine( + InMemoryFileSystem fileSystem, + INuGetFeedService feed, + PackageGraph? graph = null + ) => + new( + new ProjectDiscoveryService(fileSystem), + fileSystem, + feed, + new FakePackageGraphService(graph ?? PackageGraph.Empty), + new BaselineService(fileSystem) + ); + + private static Task RunAsync( + InMemoryFileSystem fileSystem, + INuGetFeedService? feed = null, + AnalysisOptions? options = null + ) => + CreateEngine(fileSystem, feed ?? new FakeNuGetFeed()) + .AnalyzeAsync(options ?? new AnalysisOptions { RootDirectory = Root }); +} diff --git a/CentralConfigGenerator.Core.Tests/Analysis/AnalysisReportTests.cs b/CentralConfigGenerator.Core.Tests/Analysis/AnalysisReportTests.cs new file mode 100644 index 0000000..0efa26f --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Analysis/AnalysisReportTests.cs @@ -0,0 +1,88 @@ +using CentralConfigGenerator.Core.Analysis; + +namespace CentralConfigGenerator.Core.Tests.Analysis; + +public class AnalysisReportTests +{ + [Fact] + public void HealthScore_ShouldBe100ForACleanWorkspace() => + Report([]).HealthScore.ShouldBe(100); + + [Fact] + public void HealthScore_ShouldDropSharplyForCriticalFindings() + { + var report = Report([Finding(Severity.Critical)]); + + report.HealthScore.ShouldBeLessThan(80); + report.Grade.ShouldNotBe("A"); + } + + [Fact] + public void HealthScore_ShouldBarelyMoveForInfoFindings() => + Report([Finding(Severity.Info)]).HealthScore.ShouldBeGreaterThan(95); + + [Fact] + public void HealthScore_ShouldNeverGoBelowZero() + { + var findings = Enumerable.Repeat(Finding(Severity.Critical), 100).ToList(); + + Report(findings).HealthScore.ShouldBe(0); + } + + [Fact] + public void HealthScore_ShouldScaleWithWorkspaceSize() + { + var findings = Enumerable.Repeat(Finding(Severity.Moderate), 5).ToList(); + + var small = Report(findings, packageCount: 5); + var large = Report(findings, packageCount: 500); + + large.HealthScore.ShouldBeGreaterThan(small.HealthScore); + } + + [Fact] + public void HighestSeverity_ShouldReturnTheWorstFinding() => + Report([Finding(Severity.Low), Finding(Severity.High)]).HighestSeverity + .ShouldBe(Severity.High); + + [Fact] + public void HighestSeverity_ShouldBeNeverWhenThereAreNoFindings() => + Report([]).HighestSeverity.ShouldBe(Severity.Never); + + [Theory] + [InlineData(100, "A")] + [InlineData(85, "B")] + [InlineData(75, "C")] + [InlineData(65, "D")] + [InlineData(10, "F")] + public void Grade_ShouldFollowTheScore(int targetScore, string expected) + { + // Work backwards: pick a finding count that lands near the target score. + var report = Report([], packageCount: 9); + var findings = new List(); + + while (Report(findings, packageCount: 9).HealthScore > targetScore) + { + findings.Add(Finding(Severity.Low)); + } + + report = Report(findings, packageCount: 9); + report.Grade.ShouldBe(expected); + } + + private static Finding Finding(Severity severity) => + new() + { + RuleId = "X", + Severity = severity, + Message = "m", + }; + + private static AnalysisReport Report(IReadOnlyList findings, int packageCount = 9) => + new() + { + Findings = findings, + ProjectCount = 3, + PackageCount = packageCount, + }; +} diff --git a/CentralConfigGenerator.Core.Tests/Analysis/AutoFixServiceTests.cs b/CentralConfigGenerator.Core.Tests/Analysis/AutoFixServiceTests.cs new file mode 100644 index 0000000..ec3ff78 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Analysis/AutoFixServiceTests.cs @@ -0,0 +1,177 @@ +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.NuGet; +using CentralConfigGenerator.Core.Services; +using CentralConfigGenerator.Core.Tests.TestSupport; + +namespace CentralConfigGenerator.Core.Tests.Analysis; + +public class AutoFixServiceTests +{ + private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-autofix"); + + [Fact] + public async Task PlanAsync_ShouldRemoveInlineVersionsUnderCentralManagement() + { + var project = Path.Combine(Root, "A.csproj"); + + var fileSystem = new InMemoryFileSystem() + .AddFile(project, Sample.AppProject) + .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps); + + var (service, context) = await CreateAsync(fileSystem); + + var result = await service.PlanAsync( + context, + context.References.Select(r => new Finding + { + RuleId = RuleIds.InlineVersionUnderCpm, + Severity = Severity.High, + Message = "inline", + PackageId = r.PackageId, + ProjectPath = r.ProjectPath, + Fix = FixKind.RemoveInlineVersion, + }).ToList() + ); + + result.Changes.ShouldHaveSingleItem(); + result.Changes[0].NewContent.ShouldNotContain("Version="); + result.Fixed.Count.ShouldBe(2); + } + + [Fact] + public async Task PlanAsync_ShouldSetCentralVersions() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Sample.AppProject) + .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps); + + var (service, context) = await CreateAsync(fileSystem); + + var result = await service.PlanAsync( + context, + [ + new Finding + { + RuleId = RuleIds.OutdatedPackage, + Severity = Severity.Low, + Message = "outdated", + PackageId = "Serilog", + Fix = FixKind.SetCentralVersion, + FixValue = "4.0.0", + }, + ] + ); + + result.Changes.ShouldHaveSingleItem(); + result.Changes[0].NewContent.ShouldContain("Version=\"4.0.0\""); + } + + [Fact] + public async Task PlanAsync_ShouldStackSeveralFixesOnOneFile() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "A.csproj"), Sample.AppProject) + .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps); + + var (service, context) = await CreateAsync(fileSystem); + + var result = await service.PlanAsync( + context, + [ + Fix("Serilog", "4.0.0"), + Fix("Newtonsoft.Json", "14.0.0"), + ] + ); + + result.Changes.ShouldHaveSingleItem(); + result.Changes[0].NewContent.ShouldContain("4.0.0"); + result.Changes[0].NewContent.ShouldContain("14.0.0"); + + static Finding Fix(string packageId, string version) => + new() + { + RuleId = RuleIds.OutdatedPackage, + Severity = Severity.Low, + Message = "outdated", + PackageId = packageId, + Fix = FixKind.SetCentralVersion, + FixValue = version, + }; + } + + [Fact] + public async Task PlanAsync_ShouldSkipFindingsThatAreNotAutoFixable() + { + var fileSystem = new InMemoryFileSystem().AddFile(Path.Combine(Root, "A.csproj"), Sample.AppProject); + + var (service, context) = await CreateAsync(fileSystem); + + var result = await service.PlanAsync( + context, + [ + new Finding + { + RuleId = RuleIds.FloatingVersion, + Severity = Severity.Moderate, + Message = "floating", + }, + ] + ); + + result.Skipped.ShouldHaveSingleItem(); + result.HasChanges.ShouldBeFalse(); + } + + [Fact] + public async Task ApplyAsync_ShouldWriteChangesAndBackThemUp() + { + var project = Path.Combine(Root, "A.csproj"); + + var fileSystem = new InMemoryFileSystem() + .AddFile(project, Sample.AppProject) + .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps); + + var (service, context) = await CreateAsync(fileSystem); + + var result = await service.PlanAsync( + context, + [ + new Finding + { + RuleId = RuleIds.InlineVersionUnderCpm, + Severity = Severity.High, + Message = "inline", + PackageId = "Serilog", + ProjectPath = project, + Fix = FixKind.RemoveInlineVersion, + }, + ] + ); + + var backup = await service.ApplyAsync(result, Root, Root, createBackup: true); + + fileSystem.ReadText(project).ShouldContain(""); + backup.ShouldNotBeNull(); + } + + private static async Task<(AutoFixService Service, AnalysisContext Context)> CreateAsync( + InMemoryFileSystem fileSystem + ) + { + var discovery = new ProjectDiscoveryService(fileSystem); + + var engine = new AnalysisEngine( + discovery, + fileSystem, + new FakeNuGetFeed(), + new FakePackageGraphService(PackageGraph.Empty), + new BaselineService(fileSystem) + ); + + var context = await engine.BuildContextAsync(new AnalysisOptions { RootDirectory = Root }); + var service = new AutoFixService(discovery, fileSystem, new BackupService(fileSystem)); + + return (service, context); + } +} diff --git a/CentralConfigGenerator.Core.Tests/Analysis/BaselineServiceTests.cs b/CentralConfigGenerator.Core.Tests/Analysis/BaselineServiceTests.cs new file mode 100644 index 0000000..3ec2111 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Analysis/BaselineServiceTests.cs @@ -0,0 +1,68 @@ +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Tests.TestSupport; + +namespace CentralConfigGenerator.Core.Tests.Analysis; + +public class BaselineServiceTests +{ + private static readonly string Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "ccg-baseline.json" + ); + + private static readonly Finding Sample = new() + { + RuleId = RuleIds.FloatingVersion, + Severity = Severity.Moderate, + Message = "floating", + PackageId = "xunit", + Version = "2.9.*", + }; + + [Fact] + public async Task WriteAsync_ThenLoadAsync_ShouldRoundTrip() + { + var fileSystem = new InMemoryFileSystem(); + var service = new BaselineService(fileSystem); + + await service.WriteAsync(Path, [Sample]); + var baseline = await service.LoadAsync(Path); + + baseline.ShouldNotBeNull(); + baseline.Fingerprints.ShouldContain(Sample.Fingerprint); + } + + [Fact] + public async Task LoadAsync_ShouldReturnNullWhenTheFileIsMissing() => + (await new BaselineService(new InMemoryFileSystem()).LoadAsync(Path)).ShouldBeNull(); + + [Fact] + public async Task LoadAsync_ShouldReturnNullForMalformedJson() + { + var fileSystem = new InMemoryFileSystem().AddFile(Path, "{ not json"); + + (await new BaselineService(fileSystem).LoadAsync(Path)).ShouldBeNull(); + } + + [Fact] + public void Apply_ShouldSuppressRecordedFindings() + { + var service = new BaselineService(new InMemoryFileSystem()); + var baseline = new Baseline { Fingerprints = [Sample.Fingerprint] }; + + service.Apply([Sample], baseline).ShouldBeEmpty(); + } + + [Fact] + public void Apply_ShouldKeepFindingsThatAreNotInTheBaseline() + { + var service = new BaselineService(new InMemoryFileSystem()); + var baseline = new Baseline { Fingerprints = ["Something|else|-|-"] }; + + service.Apply([Sample], baseline).ShouldHaveSingleItem(); + } + + [Fact] + public void Apply_ShouldReturnEverythingWhenThereIsNoBaseline() => + new BaselineService(new InMemoryFileSystem()).Apply([Sample], null).ShouldHaveSingleItem(); +} diff --git a/CentralConfigGenerator.Core.Tests/Analysis/ConfigurationLoaderTests.cs b/CentralConfigGenerator.Core.Tests/Analysis/ConfigurationLoaderTests.cs new file mode 100644 index 0000000..08b7ceb --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Analysis/ConfigurationLoaderTests.cs @@ -0,0 +1,131 @@ +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Configuration; +using CentralConfigGenerator.Core.Tests.TestSupport; + +namespace CentralConfigGenerator.Core.Tests.Analysis; + +public class ConfigurationLoaderTests +{ + private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-config"); + + [Fact] + public async Task LoadAsync_ShouldReadEveryKnownSetting() + { + var json = """ + { + "conflictStrategy": "Lowest", + "backup": false, + "failOn": "Critical", + "transitivePinning": true, + "excludeDirs": "samples", + "rules": { "OutdatedPackage": "none" }, + "retention": { "enabled": true, "maxBackups": 3 }, + "ignorePackages": [ "Internal.*" ] + } + """; + + var fileSystem = new InMemoryFileSystem().AddFile( + Path.Combine(Root, ConfigurationLoader.FileName), + json + ); + + var (settings, path, error) = await new ConfigurationLoader(fileSystem).LoadAsync(Root); + + error.ShouldBeNull(); + path.ShouldNotBeNull(); + settings.ShouldNotBeNull(); + settings.ConflictStrategy.ShouldBe(VersionResolutionStrategyName.Lowest); + settings.Backup.ShouldBe(false); + settings.FailOn.ShouldBe(Severity.Critical); + settings.TransitivePinning.ShouldBe(true); + settings.Retention!.MaxBackups.ShouldBe(3); + settings.IgnorePackages.ShouldNotBeNull().ShouldContain("Internal.*"); + } + + [Fact] + public async Task LoadAsync_ShouldWalkUpTheDirectoryTree() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, ConfigurationLoader.FileName), "{}") + .AddDirectory(Path.Combine(Root, "src", "App")); + + var (settings, _, _) = await new ConfigurationLoader(fileSystem).LoadAsync( + Path.Combine(Root, "src", "App") + ); + + settings.ShouldNotBeNull(); + } + + [Fact] + public async Task LoadAsync_ShouldReturnNothingWhenThereIsNoFile() + { + var (settings, path, error) = await new ConfigurationLoader( + new InMemoryFileSystem() + ).LoadAsync(Root); + + settings.ShouldBeNull(); + path.ShouldBeNull(); + error.ShouldBeNull(); + } + + [Fact] + public async Task LoadAsync_ShouldReportMalformedJson() + { + var fileSystem = new InMemoryFileSystem().AddFile( + Path.Combine(Root, ConfigurationLoader.FileName), + "{ not json" + ); + + var (settings, _, error) = await new ConfigurationLoader(fileSystem).LoadAsync(Root); + + settings.ShouldBeNull(); + error.ShouldNotBeNull(); + } + + [Fact] + public async Task LoadAsync_ShouldTolerateCommentsAndTrailingCommas() + { + var json = """ + { + // the default strategy + "conflictStrategy": "Highest", + } + """; + + var fileSystem = new InMemoryFileSystem().AddFile( + Path.Combine(Root, ConfigurationLoader.FileName), + json + ); + + var (settings, _, error) = await new ConfigurationLoader(fileSystem).LoadAsync(Root); + + error.ShouldBeNull(); + settings!.ConflictStrategy.ShouldBe(VersionResolutionStrategyName.Highest); + } + + [Fact] + public async Task ScaffoldAsync_ShouldWriteAConfigThatLoadsBack() + { + var fileSystem = new InMemoryFileSystem(); + var loader = new ConfigurationLoader(fileSystem); + + var path = await loader.ScaffoldAsync(Root, overwrite: false); + + fileSystem.ReadText(path).ShouldContain("$schema"); + + var (settings, _, error) = await loader.LoadAsync(Root); + error.ShouldBeNull(); + settings.ShouldNotBeNull(); + } + + [Fact] + public async Task ScaffoldAsync_ShouldNotOverwriteWithoutPermission() + { + var path = Path.Combine(Root, ConfigurationLoader.FileName); + var fileSystem = new InMemoryFileSystem().AddFile(path, "{ \"backup\": false }"); + + await new ConfigurationLoader(fileSystem).ScaffoldAsync(Root, overwrite: false); + + fileSystem.ReadText(path).ShouldBe("{ \"backup\": false }"); + } +} diff --git a/CentralConfigGenerator.Core.Tests/CentralConfigGenerator.Core.Tests.csproj b/CentralConfigGenerator.Core.Tests/CentralConfigGenerator.Core.Tests.csproj index 0ebbfec..f04bff7 100644 --- a/CentralConfigGenerator.Core.Tests/CentralConfigGenerator.Core.Tests.csproj +++ b/CentralConfigGenerator.Core.Tests/CentralConfigGenerator.Core.Tests.csproj @@ -1,5 +1,7 @@ + net9.0 + Major false true diff --git a/CentralConfigGenerator.Core.Tests/Discovery/ProjectDiscoveryServiceTests.cs b/CentralConfigGenerator.Core.Tests/Discovery/ProjectDiscoveryServiceTests.cs new file mode 100644 index 0000000..02d7778 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Discovery/ProjectDiscoveryServiceTests.cs @@ -0,0 +1,136 @@ +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Tests.TestSupport; + +namespace CentralConfigGenerator.Core.Tests.Discovery; + +public class ProjectDiscoveryServiceTests +{ + private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-discovery"); + + [Fact] + public async Task DiscoverAsync_ShouldFindProjectsRecursively() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "src", "App", "App.csproj"), Sample.AppProject) + .AddFile(Path.Combine(Root, "src", "Lib", "Lib.fsproj"), Sample.LibProject); + + var service = new ProjectDiscoveryService(fileSystem); + + var projects = await service.DiscoverAsync(new DiscoveryOptions { RootDirectory = Root }); + + projects.Count.ShouldBe(2); + projects.Select(p => p.ResolvedKind).ShouldContain(ProjectKind.FSharp); + } + + [Fact] + public async Task DiscoverAsync_ShouldSkipBinObjAndBackupDirectories() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "App.csproj"), Sample.AppProject) + .AddFile(Path.Combine(Root, "bin", "Debug", "Ghost.csproj"), Sample.AppProject) + .AddFile(Path.Combine(Root, "obj", "Ghost.csproj"), Sample.AppProject) + .AddFile(Path.Combine(Root, "node_modules", "Ghost.csproj"), Sample.AppProject) + .AddFile( + Path.Combine(Root, ".centralconfig-backups", "20250101", "App.csproj"), + Sample.AppProject + ); + + var service = new ProjectDiscoveryService(fileSystem); + + var projects = await service.DiscoverAsync(new DiscoveryOptions { RootDirectory = Root }); + + projects.Count.ShouldBe(1); + projects[0].FileName.ShouldBe("App.csproj"); + } + + [Fact] + public async Task DiscoverAsync_ShouldHonourExcludeRegex() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "src", "App.csproj"), Sample.AppProject) + .AddFile(Path.Combine(Root, "samples", "Demo.csproj"), Sample.AppProject); + + var service = new ProjectDiscoveryService(fileSystem); + + var projects = await service.DiscoverAsync( + new DiscoveryOptions { RootDirectory = Root, ExcludePattern = "^samples$" } + ); + + projects.Count.ShouldBe(1); + projects[0].FileName.ShouldBe("App.csproj"); + } + + [Fact] + public async Task DiscoverAsync_ShouldRestrictToRequestedKinds() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "App.csproj"), Sample.AppProject) + .AddFile(Path.Combine(Root, "Lib.fsproj"), Sample.LibProject); + + var service = new ProjectDiscoveryService(fileSystem); + + var projects = await service.DiscoverAsync( + new DiscoveryOptions { RootDirectory = Root, IncludedKinds = [ProjectKind.CSharp] } + ); + + projects.Count.ShouldBe(1); + projects[0].ResolvedKind.ShouldBe(ProjectKind.CSharp); + } + + [Fact] + public async Task DiscoverAsync_ShouldFollowSolutionWhenGiven() + { + var solutionPath = Path.Combine(Root, "My.sln"); + var projectPath = Path.Combine(Root, "src", "App", "App.csproj"); + + var fileSystem = new InMemoryFileSystem() + .AddFile(projectPath, Sample.AppProject) + .AddFile(Path.Combine(Root, "unlisted", "Other.csproj"), Sample.AppProject) + .AddFile( + solutionPath, + """ + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "src/App/App.csproj", "{G}" + EndProject + """ + ); + + var service = new ProjectDiscoveryService(fileSystem); + + var projects = await service.DiscoverAsync( + new DiscoveryOptions { RootDirectory = Root, SolutionPath = solutionPath } + ); + + projects.Count.ShouldBe(1); + projects[0].Path.ShouldBe(projectPath); + } + + [Fact] + public async Task DiscoverAsync_ShouldReturnEmptyForMissingDirectory() + { + var service = new ProjectDiscoveryService(new InMemoryFileSystem()); + + var projects = await service.DiscoverAsync( + new DiscoveryOptions { RootDirectory = Path.Combine(Root, "nope") } + ); + + projects.ShouldBeEmpty(); + } + + [Fact] + public async Task SaveAsync_ShouldPreserveTheOriginalFormat() + { + var path = Path.Combine(Root, "App.csproj"); + var fileSystem = new InMemoryFileSystem(); + fileSystem.AddFile(path, System.Text.Encoding.UTF8.GetBytes("\r\n\r\n")); + + var service = new ProjectDiscoveryService(fileSystem); + var project = await service.LoadAsync(path); + + project.ShouldNotBeNull(); + await service.SaveAsync(project); + + System.Text.Encoding.UTF8.GetString(fileSystem.ReadBytes(path)) + .ShouldBe("\r\n\r\n"); + } +} diff --git a/CentralConfigGenerator.Core.Tests/Discovery/SolutionReaderTests.cs b/CentralConfigGenerator.Core.Tests/Discovery/SolutionReaderTests.cs new file mode 100644 index 0000000..9517c58 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Discovery/SolutionReaderTests.cs @@ -0,0 +1,103 @@ +using CentralConfigGenerator.Core.Discovery; + +namespace CentralConfigGenerator.Core.Tests.Discovery; + +public class SolutionReaderTests +{ + [Theory] + [InlineData("MySolution.sln", true)] + [InlineData("MySolution.slnx", true)] + [InlineData("MySolution.slnf", true)] + [InlineData("MyProject.csproj", false)] + public void IsSolution_ShouldRecogniseSolutionExtensions(string path, bool expected) => + SolutionReader.IsSolution(path).ShouldBe(expected); + + [Fact] + public void ReadProjects_ShouldParseClassicSlnFormat() + { + var content = """ + Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "src\App\App.csproj", "{GUID1}" + EndProject + Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{FOLDER}" + EndProject + Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Lib", "src\Lib\Lib.fsproj", "{GUID2}" + EndProject + """; + + var projects = SolutionReader.ReadProjects( + Path.Combine(Path.GetTempPath(), "MySolution.sln"), + content + ); + + projects.Count.ShouldBe(2); + projects.ShouldContain(p => p.EndsWith("App.csproj", StringComparison.Ordinal)); + projects.ShouldContain(p => p.EndsWith("Lib.fsproj", StringComparison.Ordinal)); + } + + [Fact] + public void ReadProjects_ShouldSkipSolutionFolders() + { + var content = """ + Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FOLDER}" + EndProject + """; + + SolutionReader + .ReadProjects(Path.Combine(Path.GetTempPath(), "S.sln"), content) + .ShouldBeEmpty(); + } + + [Fact] + public void ReadProjects_ShouldParseSlnxFormat() + { + var content = """ + + + + + + + """; + + var projects = SolutionReader.ReadProjects( + Path.Combine(Path.GetTempPath(), "MySolution.slnx"), + content + ); + + projects.Count.ShouldBe(2); + } + + [Fact] + public void ReadProjects_ShouldParseSolutionFilter() + { + var content = """ + { + "solution": { + "path": "MySolution.sln", + "projects": [ "src/App/App.csproj" ] + } + } + """; + + var projects = SolutionReader.ReadProjects( + Path.Combine(Path.GetTempPath(), "MySolution.slnf"), + content + ); + + projects.Count.ShouldBe(1); + projects[0].ShouldEndWith("App.csproj"); + } + + [Fact] + public void ReadProjects_ShouldReturnEmptyForMalformedXml() => + SolutionReader + .ReadProjects(Path.Combine(Path.GetTempPath(), "Broken.slnx"), "") + .ShouldBeEmpty(); + + [Fact] + public void ReadProjects_ShouldReturnEmptyForMalformedJson() => + SolutionReader + .ReadProjects(Path.Combine(Path.GetTempPath(), "Broken.slnf"), "{ not json") + .ShouldBeEmpty(); +} diff --git a/CentralConfigGenerator.Core.Tests/IO/EncodingDetectorTests.cs b/CentralConfigGenerator.Core.Tests/IO/EncodingDetectorTests.cs new file mode 100644 index 0000000..bc978b5 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/IO/EncodingDetectorTests.cs @@ -0,0 +1,95 @@ +using System.Text; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Tests.IO; + +public class EncodingDetectorTests +{ + [Fact] + public void Decode_ShouldDetectUtf8WithoutBom() + { + var bytes = new UTF8Encoding(false).GetBytes(""); + + var (content, format) = EncodingDetector.Decode(bytes); + + content.ShouldBe(""); + format.HasByteOrderMark.ShouldBeFalse(); + } + + [Fact] + public void Decode_ShouldDetectUtf8Bom() + { + var bytes = new UTF8Encoding(true).GetPreamble() + .Concat(new UTF8Encoding(false).GetBytes("")) + .ToArray(); + + var (content, format) = EncodingDetector.Decode(bytes); + + content.ShouldBe(""); + format.HasByteOrderMark.ShouldBeTrue(); + } + + [Fact] + public void Decode_ShouldDetectUtf16LittleEndian() + { + var bytes = new UnicodeEncoding(false, true).GetPreamble() + .Concat(new UnicodeEncoding(false, false).GetBytes("")) + .ToArray(); + + var (content, format) = EncodingDetector.Decode(bytes); + + content.ShouldBe(""); + format.HasByteOrderMark.ShouldBeTrue(); + format.Encoding.ShouldBeOfType(); + } + + [Theory] + [InlineData("a\r\nb\r\nc", LineEndingStyle.CrLf)] + [InlineData("a\nb\nc", LineEndingStyle.Lf)] + [InlineData("a\rb\rc", LineEndingStyle.Cr)] + [InlineData("single line", LineEndingStyle.Preserve)] + public void Decode_ShouldDetectLineEndings(string content, LineEndingStyle expected) + { + var (_, format) = EncodingDetector.Decode(Encoding.UTF8.GetBytes(content)); + + format.LineEnding.ShouldBe(expected); + } + + [Fact] + public void RoundTrip_ShouldPreserveBytesExactly() + { + var original = new UTF8Encoding(true).GetPreamble() + .Concat(new UTF8Encoding(false).GetBytes("\r\n \r\n\r\n")) + .ToArray(); + + var (content, format) = EncodingDetector.Decode(original); + var round = EncodingDetector.Encode(content, format); + + round.ShouldBe(original); + } + + [Fact] + public void Encode_ShouldRewriteLineEndingsWhenAsked() + { + var (content, format) = EncodingDetector.Decode(Encoding.UTF8.GetBytes("a\r\nb\r\n")); + + var bytes = EncodingDetector.Encode(content, format with { LineEnding = LineEndingStyle.Lf }); + + Encoding.UTF8.GetString(bytes).ShouldBe("a\nb\n"); + } + + [Theory] + [InlineData("utf-8")] + [InlineData("UTF-8")] + [InlineData("us-ascii")] + public void Resolve_ShouldAcceptIanaNames(string name) => + EncodingDetector.Resolve(name).ShouldNotBeNull(); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not-a-real-encoding")] + public void Resolve_ShouldReturnNullForUnknownNames(string? name) => + EncodingDetector.Resolve(name).ShouldBeNull(); +} diff --git a/CentralConfigGenerator.Core.Tests/Migration/MigrationServiceTests.cs b/CentralConfigGenerator.Core.Tests/Migration/MigrationServiceTests.cs new file mode 100644 index 0000000..6e491c8 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Migration/MigrationServiceTests.cs @@ -0,0 +1,252 @@ +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Services; +using CentralConfigGenerator.Core.Tests.TestSupport; + +namespace CentralConfigGenerator.Core.Tests.Migration; + +public class MigrationServiceTests +{ + private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-migration"); + + [Fact] + public async Task PlanAsync_ShouldCreateThePropsFileAndStripEveryProject() + { + var (service, _) = Create(Workspace()); + + var plan = await service.PlanAsync(Options()); + + plan.ProjectCount.ShouldBe(2); + plan.Changes.ShouldContain(c => c.Kind == FileChangeKind.Created); + plan.Changes.Count(c => c.Kind == FileChangeKind.Modified).ShouldBe(2); + plan.ResolvedVersions["Newtonsoft.Json"].ShouldBe("13.0.3"); + } + + [Fact] + public async Task PlanAsync_ShouldNotWriteAnythingToDisk() + { + var fileSystem = Workspace(); + var (service, _) = Create(fileSystem); + + await service.PlanAsync(Options()); + + fileSystem.FileExists(Path.Combine(Root, "Directory.Packages.props")).ShouldBeFalse(); + } + + [Fact] + public async Task PlanAsync_ShouldRecordConflicts() + { + var (service, _) = Create(Workspace()); + + var plan = await service.PlanAsync(Options()); + + plan.Conflicts.ShouldContain(c => c.PackageId == "Newtonsoft.Json"); + plan.Conflicts.First(c => c.PackageId == "Newtonsoft.Json").ResolvedVersion.ShouldBe("13.0.3"); + } + + [Fact] + public async Task PlanAsync_ShouldRespectTheLowestStrategy() + { + var (service, _) = Create(Workspace()); + + var plan = await service.PlanAsync( + Options() with { ConflictStrategy = VersionResolutionStrategy.Lowest } + ); + + plan.ResolvedVersions["Newtonsoft.Json"].ShouldBe("13.0.1"); + } + + [Fact] + public async Task PlanAsync_ShouldThrowForTheFailStrategy() + { + var (service, _) = Create(Workspace()); + + await Should.ThrowAsync(() => + service.PlanAsync( + Options() with + { + ConflictStrategy = VersionResolutionStrategy.Fail, + FailOnConflict = true, + } + ) + ); + } + + [Fact] + public async Task PlanAsync_ShouldUseTheCallerSuppliedResolution() + { + var (service, _) = Create(Workspace()); + + var plan = await service.PlanAsync(Options(), _ => "99.0.0"); + + plan.ResolvedVersions["Newtonsoft.Json"].ShouldBe("99.0.0"); + } + + [Fact] + public async Task PlanAsync_ShouldKeepInlineVersionsWhenAsked() + { + var (service, _) = Create(Workspace()); + + var plan = await service.PlanAsync(Options() with { KeepInlineVersions = true }); + + plan.Changes.Count.ShouldBe(1); + plan.Changes[0].Kind.ShouldBe(FileChangeKind.Created); + } + + [Fact] + public async Task PlanAsync_ShouldMergeIntoAnExistingPropsFile() + { + var fileSystem = Workspace() + .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps); + + var (service, _) = Create(fileSystem); + + var plan = await service.PlanAsync(Options() with { Merge = true }); + + var change = plan.Changes.First(c => c.Path.EndsWith("Directory.Packages.props", StringComparison.Ordinal)); + change.Kind.ShouldBe(FileChangeKind.Modified); + change.NewContent.ShouldContain("Newtonsoft.Json"); + } + + [Fact] + public async Task PlanAsync_ShouldAlsoHoistPropertiesWhenUnifyingIsOn() + { + var (service, _) = Create(Workspace()); + + var plan = await service.PlanAsync(Options() with { UnifyProperties = true }); + + plan.Changes.ShouldContain(c => c.Path.EndsWith("Directory.Build.props", StringComparison.Ordinal)); + } + + [Fact] + public async Task PlanAsync_ShouldReturnAnEmptyPlanWhenNoProjectsExist() + { + var (service, _) = Create(new InMemoryFileSystem().AddDirectory(Root)); + + var plan = await service.PlanAsync(Options()); + + plan.ProjectCount.ShouldBe(0); + plan.HasChanges.ShouldBeFalse(); + } + + [Fact] + public async Task ApplyAsync_ShouldWriteEveryChangeAndTakeABackup() + { + var fileSystem = Workspace(); + var (service, _) = Create(fileSystem); + + var plan = await service.PlanAsync(Options()); + var backup = await service.ApplyAsync(plan, Options()); + + fileSystem.FileExists(Path.Combine(Root, "Directory.Packages.props")).ShouldBeTrue(); + fileSystem.ReadText(Path.Combine(Root, "a", "A.csproj")).ShouldNotContain("Version="); + backup.ShouldNotBeNull(); + backup.Files.Count.ShouldBe(3); + } + + [Fact] + public async Task ApplyAsync_ShouldSkipTheBackupWhenDisabled() + { + var (service, _) = Create(Workspace()); + + var plan = await service.PlanAsync(Options()); + var backup = await service.ApplyAsync(plan, Options() with { CreateBackup = false }); + + backup.ShouldBeNull(); + } + + [Fact] + public async Task ApplyAsync_ShouldRewriteLineEndingsWhenRequested() + { + var fileSystem = new InMemoryFileSystem().AddFile( + Path.Combine(Root, "a", "A.csproj"), + System.Text.Encoding.UTF8.GetBytes( + "\r\n \r\n \r\n \r\n\r\n" + ) + ); + + var (service, _) = Create(fileSystem); + var options = Options() with { LineEnding = LineEndingStyle.Lf, CreateBackup = false }; + + var plan = await service.PlanAsync(options); + await service.ApplyAsync(plan, options); + + var written = System.Text.Encoding.UTF8.GetString( + fileSystem.ReadBytes(Path.Combine(Root, "a", "A.csproj")) + ); + + written.ShouldNotContain("\r\n"); + } + + [Fact] + public async Task PlanRevertAsync_ShouldInlineVersionsAndDeleteThePropsFile() + { + var fileSystem = new InMemoryFileSystem() + .AddFile( + Path.Combine(Root, "a", "A.csproj"), + """ + + + + + + """ + ) + .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps); + + var (service, _) = Create(fileSystem); + + var plan = await service.PlanRevertAsync(Options()); + + plan.Changes.ShouldContain(c => c.Kind == FileChangeKind.Deleted); + plan.Changes.First(c => c.Kind == FileChangeKind.Modified).NewContent + .ShouldContain("Version=\"3.1.1\""); + } + + [Fact] + public async Task PlanRevertAsync_ShouldReportPackagesWithNoCentralVersion() + { + var fileSystem = new InMemoryFileSystem() + .AddFile( + Path.Combine(Root, "a", "A.csproj"), + """ + + + + + + """ + ) + .AddFile(Path.Combine(Root, "Directory.Packages.props"), Sample.PackagesProps); + + var (service, _) = Create(fileSystem); + + var plan = await service.PlanRevertAsync(Options()); + + plan.Findings.ShouldContain(f => f.RuleId == Core.Analysis.RuleIds.MissingCentralVersion); + } + + private static MigrationOptions Options() => + new() { RootDirectory = Root, BackupDirectory = Root }; + + private static InMemoryFileSystem Workspace() => + new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "a", "A.csproj"), Sample.AppProject) + .AddFile(Path.Combine(Root, "b", "B.csproj"), Sample.LibProject); + + private static (MigrationService Service, InMemoryFileSystem FileSystem) Create( + InMemoryFileSystem fileSystem + ) + { + var discovery = new ProjectDiscoveryService(fileSystem); + var service = new MigrationService( + discovery, + fileSystem, + new BackupService(fileSystem), + new VersionSelector(new VersionConflictResolver()) + ); + + return (service, fileSystem); + } +} diff --git a/CentralConfigGenerator.Core.Tests/Migration/PackagesPropsEditorTests.cs b/CentralConfigGenerator.Core.Tests/Migration/PackagesPropsEditorTests.cs new file mode 100644 index 0000000..c739291 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Migration/PackagesPropsEditorTests.cs @@ -0,0 +1,74 @@ +using CentralConfigGenerator.Core.Generators; +using CentralConfigGenerator.Core.Tests.TestSupport; + +namespace CentralConfigGenerator.Core.Tests.Migration; + +public class PackagesPropsEditorTests +{ + [Fact] + public void SetVersion_ShouldUpdateAnExistingEntry() + { + var updated = PackagesPropsEditor.SetVersion(Sample.PackagesProps, "Serilog", "4.0.0"); + + updated.ShouldNotBeNull(); + updated.ShouldContain("Include=\"Serilog\" Version=\"4.0.0\""); + } + + [Fact] + public void SetVersion_ShouldReturnNullWhenNothingChanges() => + PackagesPropsEditor.SetVersion(Sample.PackagesProps, "Serilog", "3.1.1").ShouldBeNull(); + + [Fact] + public void SetVersion_ShouldAppendANewEntry() + { + var updated = PackagesPropsEditor.SetVersion(Sample.PackagesProps, "Polly", "8.4.1"); + + updated.ShouldNotBeNull(); + updated.ShouldContain(""); + updated.ShouldContain("Serilog"); + } + + [Fact] + public void RemoveEntry_ShouldDeleteThePackageVersion() + { + var updated = PackagesPropsEditor.RemoveEntry(Sample.PackagesProps, "Serilog"); + + updated.ShouldNotBeNull(); + updated.ShouldNotContain("Serilog"); + updated.ShouldContain("Newtonsoft.Json"); + } + + [Fact] + public void RemoveEntry_ShouldReturnNullForAnUnknownPackage() => + PackagesPropsEditor.RemoveEntry(Sample.PackagesProps, "Nope").ShouldBeNull(); + + [Fact] + public void NormalizeCasing_ShouldRewriteTheIncludeAttribute() + { + var content = Sample.PackagesProps.Replace("Newtonsoft.Json", "newtonsoft.json"); + + var updated = PackagesPropsEditor.NormalizeCasing(content, "Newtonsoft.Json"); + + updated.ShouldNotBeNull(); + updated.ShouldContain("Include=\"Newtonsoft.Json\""); + } + + [Fact] + public void DeduplicateEntries_ShouldKeepOnlyTheFirstSpelling() + { + var content = """ + + + + + + + """; + + var updated = PackagesPropsEditor.DeduplicateEntries(content); + + updated.ShouldNotBeNull(); + updated.ShouldContain("Version=\"3.1.1\""); + updated.ShouldNotContain("2.0.0"); + } +} diff --git a/CentralConfigGenerator.Core.Tests/Migration/PackagesPropsWriterTests.cs b/CentralConfigGenerator.Core.Tests/Migration/PackagesPropsWriterTests.cs new file mode 100644 index 0000000..fa4a0ec --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Migration/PackagesPropsWriterTests.cs @@ -0,0 +1,123 @@ +using CentralConfigGenerator.Core.Generators; + +namespace CentralConfigGenerator.Core.Tests.Migration; + +public class PackagesPropsWriterTests +{ + private static readonly Dictionary Versions = new(StringComparer.OrdinalIgnoreCase) + { + ["Serilog"] = "3.1.1", + ["Newtonsoft.Json"] = "13.0.3", + }; + + [Fact] + public void Create_ShouldEnableCentralManagementAndSortEntries() + { + var content = PackagesPropsWriter.Create(Versions); + + content.ShouldContain("true"); + content.IndexOf("Newtonsoft.Json", StringComparison.Ordinal) + .ShouldBeLessThan(content.IndexOf("Serilog", StringComparison.Ordinal)); + } + + [Fact] + public void Create_ShouldEmitTransitivePinningWhenRequested() + { + var content = PackagesPropsWriter.Create( + Versions, + new PackagesPropsOptions { TransitivePinning = true } + ); + + content.ShouldContain("true"); + } + + [Fact] + public void Create_ShouldEmitGlobalPackageReferencesSeparately() + { + var content = PackagesPropsWriter.Create( + Versions, + new PackagesPropsOptions { GlobalPackages = ["Serilog"] } + ); + + content.ShouldContain(""); + content.ShouldNotContain(" { ["A&B"] = "1.0.0" } + ); + + content.ShouldContain("A&B"); + } + + [Fact] + public void Merge_ShouldAddMissingEntriesAndKeepComments() + { + var existing = """ + + + true + + + + + + + """; + + var merged = PackagesPropsWriter.Merge(existing, Versions); + + merged.ShouldContain(""); + merged.ShouldContain("Include=\"Serilog\" Version=\"3.1.1\""); + merged.ShouldContain("Newtonsoft.Json"); + } + + [Fact] + public void Merge_ShouldAddTransitivePinningToAnExistingPropertyGroup() + { + var existing = """ + + + true + + + """; + + var merged = PackagesPropsWriter.Merge( + existing, + Versions, + new PackagesPropsOptions { TransitivePinning = true } + ); + + merged.ShouldContain("CentralPackageTransitivePinningEnabled"); + } + + [Fact] + public void Merge_ShouldFallBackToCreateForUnparseableInput() + { + var merged = PackagesPropsWriter.Merge("", Versions); + + merged.ShouldContain("true"); + } + + [Fact] + public void Merge_ShouldUpdateChildElementVersions() + { + var existing = """ + + + + 1.0.0 + + + + """; + + var merged = PackagesPropsWriter.Merge(existing, Versions); + + merged.ShouldContain("3.1.1"); + } +} diff --git a/CentralConfigGenerator.Core.Tests/Migration/ProjectRewriterTests.cs b/CentralConfigGenerator.Core.Tests/Migration/ProjectRewriterTests.cs new file mode 100644 index 0000000..bc85450 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Migration/ProjectRewriterTests.cs @@ -0,0 +1,185 @@ +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Tests.TestSupport; + +namespace CentralConfigGenerator.Core.Tests.Migration; + +public class ProjectRewriterTests +{ + [Fact] + public void StripVersions_ShouldRemoveAttributeVersions() + { + var updated = ProjectRewriter.StripVersions(Project(Sample.AppProject), out var touched); + + updated.ShouldNotBeNull(); + updated.ShouldNotContain("Version="); + updated.ShouldContain(""); + touched.Count.ShouldBe(2); + } + + [Fact] + public void StripVersions_ShouldCollapseChildElementVersions() + { + var updated = ProjectRewriter.StripVersions(Project(Sample.LibProject), out _); + + updated.ShouldNotBeNull(); + updated.ShouldNotContain(""); + updated.ShouldContain(""); + } + + [Fact] + public void StripVersions_ShouldPreserveEverythingElse() + { + var updated = ProjectRewriter.StripVersions(Project(Sample.AppProject), out _); + + updated.ShouldNotBeNull(); + updated.ShouldContain("Exe"); + updated.ShouldContain("net8.0"); + } + + [Fact] + public void StripVersions_ShouldReturnNullWhenThereIsNothingToDo() + { + var content = """ + + + + + + """; + + ProjectRewriter.StripVersions(Project(content), out _).ShouldBeNull(); + } + + [Fact] + public void StripVersion_ShouldOnlyTouchTheNamedPackage() + { + var updated = ProjectRewriter.StripVersion(Project(Sample.AppProject), "Serilog"); + + updated.ShouldNotBeNull(); + updated.ShouldContain("Include=\"Newtonsoft.Json\" Version=\"13.0.1\""); + updated.ShouldContain(""); + } + + [Fact] + public void RestoreVersions_ShouldInlineCentralVersionsAfterInclude() + { + var content = """ + + + + + + """; + + var updated = ProjectRewriter.RestoreVersions( + Project(content), + new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Serilog"] = "3.1.1" }, + out var unresolved + ); + + updated.ShouldNotBeNull(); + updated.ShouldContain("Include=\"Serilog\" Version=\"3.1.1\" PrivateAssets=\"all\""); + unresolved.ShouldBeEmpty(); + } + + [Fact] + public void RestoreVersions_ShouldReportPackagesWithNoCentralEntry() + { + var content = """ + + + + + + """; + + ProjectRewriter.RestoreVersions( + Project(content), + new Dictionary(), + out var unresolved + ); + + unresolved.ShouldBe(["Unknown"]); + } + + [Fact] + public void RemoveProperties_ShouldDeleteMatchingValuesOnly() + { + var updated = ProjectRewriter.RemoveProperties( + Project(Sample.AppProject), + ["Nullable", "TargetFramework"], + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Nullable"] = "enable", + ["TargetFramework"] = "net9.0", + } + ); + + updated.ShouldNotBeNull(); + updated.ShouldNotContain(""); + // net8.0 does not match the expected net9.0, so it must stay. + updated.ShouldContain("net8.0"); + } + + [Fact] + public void RemoveProperties_ShouldLeaveConditionedGroupsAlone() + { + var content = """ + + + enable + + + """; + + ProjectRewriter.RemoveProperties(Project(content), ["Nullable"]).ShouldBeNull(); + } + + [Fact] + public void RemoveDuplicateReference_ShouldKeepTheFirstOccurrence() + { + var content = """ + + + + + + + """; + + var updated = ProjectRewriter.RemoveDuplicateReference(Project(content), "xunit"); + + updated.ShouldNotBeNull(); + updated.Split(" + ProjectRewriter.RemoveDuplicateReference(Project(Sample.AppProject), "Serilog").ShouldBeNull(); + + [Fact] + public void NormalizeCasing_ShouldRewriteToCanonicalSpelling() + { + var content = """ + + + + + + """; + + var updated = ProjectRewriter.NormalizeCasing(Project(content), "Newtonsoft.Json"); + + updated.ShouldNotBeNull(); + updated.ShouldContain("Include=\"Newtonsoft.Json\""); + } + + [Fact] + public void NormalizeCasing_ShouldReturnNullWhenAlreadyCanonical() => + ProjectRewriter.NormalizeCasing(Project(Sample.AppProject), "Newtonsoft.Json").ShouldBeNull(); + + private static ProjectFile Project(string content) => + new() { Path = Path.Combine(Path.GetTempPath(), "Test.csproj"), Content = content }; +} diff --git a/CentralConfigGenerator.Core.Tests/Migration/PropertyHoisterTests.cs b/CentralConfigGenerator.Core.Tests/Migration/PropertyHoisterTests.cs new file mode 100644 index 0000000..29f27cc --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Migration/PropertyHoisterTests.cs @@ -0,0 +1,86 @@ +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Tests.Migration; + +public class PropertyHoisterTests +{ + [Fact] + public void FindHoistableProperties_ShouldReturnUnanimousValues() + { + var projects = new[] + { + Project("enable"), + Project("enable"), + }; + + var result = PropertyHoister.FindHoistableProperties( + projects, + BuildPropertyDefaults.Recommended + ); + + result["Nullable"].ShouldBe("enable"); + } + + [Fact] + public void FindHoistableProperties_ShouldSkipPropertiesThatDisagree() + { + var projects = new[] + { + Project("net8.0"), + Project("net9.0"), + }; + + PropertyHoister + .FindHoistableProperties(projects, BuildPropertyDefaults.Recommended) + .ShouldNotContainKey("TargetFramework"); + } + + [Fact] + public void FindHoistableProperties_ShouldRequireAtLeastTwoProjects() + { + var projects = new[] + { + Project("enable"), + }; + + PropertyHoister + .FindHoistableProperties(projects, BuildPropertyDefaults.Recommended) + .ShouldBeEmpty(); + } + + [Fact] + public void FindHoistableProperties_ShouldNeverHoistIdentityProperties() + { + var projects = new[] + { + Project("Same"), + Project("Same"), + }; + + PropertyHoister + .FindHoistableProperties(projects, ["AssemblyName"]) + .ShouldNotContainKey("AssemblyName"); + } + + [Fact] + public void FindHoistableProperties_ShouldIgnoreConditionedGroups() + { + var projects = new[] + { + Project("enable"), + Project("enable"), + }; + + PropertyHoister + .FindHoistableProperties(projects, BuildPropertyDefaults.Recommended) + .ShouldBeEmpty(); + } + + private static ProjectFile Project(string body) => + new() + { + Path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".csproj"), + Content = $"{body}", + }; +} diff --git a/CentralConfigGenerator.Core.Tests/Reporting/ReportWriterTests.cs b/CentralConfigGenerator.Core.Tests/Reporting/ReportWriterTests.cs new file mode 100644 index 0000000..a908257 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Reporting/ReportWriterTests.cs @@ -0,0 +1,146 @@ +using System.Text.Json; +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Reporting; + +namespace CentralConfigGenerator.Core.Tests.Reporting; + +public class ReportWriterTests +{ + private static readonly AnalysisReport Report = new() + { + ProjectCount = 2, + PackageCount = 3, + RootDirectory = Path.Combine(Path.GetTempPath(), "ccg-report"), + Duration = TimeSpan.FromSeconds(1.5), + Findings = + [ + new Finding + { + RuleId = RuleIds.SecurityVulnerability, + Severity = Severity.Critical, + Message = "'Pkg' 1.0.0 has a known advisory.", + PackageId = "Pkg", + Version = "1.0.0", + ProjectPath = Path.Combine(Path.GetTempPath(), "ccg-report", "src", "A.csproj"), + Recommendation = "Upgrade to 1.0.1.", + Fix = FixKind.SetCentralVersion, + FixValue = "1.0.1", + }, + new Finding + { + RuleId = RuleIds.FloatingVersion, + Severity = Severity.Moderate, + Message = "'xunit' uses a floating version.", + PackageId = "xunit", + }, + ], + }; + + [Fact] + public void Json_ShouldProduceParseableOutputWithASummary() + { + var json = new JsonReportWriter().Write(Report); + + using var document = JsonDocument.Parse(json); + var summary = document.RootElement.GetProperty("summary"); + + summary.GetProperty("projectCount").GetInt32().ShouldBe(2); + summary.GetProperty("bySeverity").GetProperty("critical").GetInt32().ShouldBe(1); + document.RootElement.GetProperty("findings").GetArrayLength().ShouldBe(2); + } + + [Fact] + public void Json_ShouldNotEscapeApostrophes() => + new JsonReportWriter().Write(Report).ShouldContain("'Pkg'"); + + [Fact] + public void Json_ShouldEmitWorkspaceRelativeProjectPaths() + { + var json = new JsonReportWriter().Write(Report); + + json.ShouldContain("src/A.csproj"); + } + + [Fact] + public void Sarif_ShouldEmitValidSchemaAndRules() + { + var sarif = new SarifReportWriter().Write(Report); + + using var document = JsonDocument.Parse(sarif); + document.RootElement.GetProperty("version").GetString().ShouldBe("2.1.0"); + document.RootElement.TryGetProperty("$schema", out _).ShouldBeTrue(); + + var run = document.RootElement.GetProperty("runs")[0]; + run.GetProperty("results").GetArrayLength().ShouldBe(2); + run.GetProperty("tool").GetProperty("driver").GetProperty("rules").GetArrayLength().ShouldBe(2); + } + + [Fact] + public void Sarif_ShouldMapCriticalToError() + { + var sarif = new SarifReportWriter().Write(Report); + + using var document = JsonDocument.Parse(sarif); + document + .RootElement.GetProperty("runs")[0] + .GetProperty("results")[0] + .GetProperty("level") + .GetString() + .ShouldBe("error"); + } + + [Fact] + public void Markdown_ShouldLeadWithTheVerdict() + { + var markdown = new MarkdownReportWriter().Write(Report); + + markdown.ShouldStartWith("## Dependency health:"); + markdown.ShouldContain("Critical issues found"); + markdown.ShouldContain(" + ReportWriterFactory.Create(format).Format.ShouldBe(format); +} diff --git a/CentralConfigGenerator.Core.Tests/Services/BackupServiceTests.cs b/CentralConfigGenerator.Core.Tests/Services/BackupServiceTests.cs new file mode 100644 index 0000000..d6e41ad --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Services/BackupServiceTests.cs @@ -0,0 +1,156 @@ +using CentralConfigGenerator.Core.Services; +using CentralConfigGenerator.Core.Tests.TestSupport; + +namespace CentralConfigGenerator.Core.Tests.Services; + +public class BackupServiceTests +{ + private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-backup"); + + [Fact] + public async Task CreateAsync_ShouldCopyEveryRequestedFile() + { + var path = Path.Combine(Root, "App.csproj"); + var fileSystem = new InMemoryFileSystem().AddFile(path, ""); + var service = new BackupService(fileSystem); + + var backup = await service.CreateAsync(Root, Root, "migrate", [path]); + + backup.ShouldNotBeNull(); + backup.Files.Count.ShouldBe(1); + backup.Operation.ShouldBe("migrate"); + } + + [Fact] + public async Task CreateAsync_ShouldReturnNullWhenNoFilesAreGiven() + { + var service = new BackupService(new InMemoryFileSystem()); + + (await service.CreateAsync(Root, Root, "migrate", [])).ShouldBeNull(); + } + + [Fact] + public async Task RestoreAsync_ShouldPutTheOriginalContentBack() + { + var path = Path.Combine(Root, "App.csproj"); + var fileSystem = new InMemoryFileSystem().AddFile(path, "original"); + var service = new BackupService(fileSystem); + + var backup = await service.CreateAsync(Root, Root, "migrate", [path]); + await fileSystem.WriteAllTextAsync(path, "modified"); + + await service.RestoreAsync(Root, backup!.Id); + + fileSystem.ReadText(path).ShouldBe("original"); + } + + [Fact] + public async Task RestoreAsync_ShouldDeleteFilesThatTheOperationCreated() + { + var path = Path.Combine(Root, "Directory.Packages.props"); + var fileSystem = new InMemoryFileSystem().AddDirectory(Root); + var service = new BackupService(fileSystem); + + // The file does not exist yet, so the backup records it as "created". + var backup = await service.CreateAsync(Root, Root, "migrate", [path]); + await fileSystem.WriteAllTextAsync(path, ""); + + await service.RestoreAsync(Root, backup!.Id); + + fileSystem.FileExists(path).ShouldBeFalse(); + } + + [Fact] + public async Task RestoreLatestAsync_ShouldPickTheNewestBackup() + { + var path = Path.Combine(Root, "App.csproj"); + var fileSystem = new InMemoryFileSystem().AddFile(path, "v1"); + var service = new BackupService(fileSystem); + + await service.CreateAsync(Root, Root, "first", [path]); + await fileSystem.WriteAllTextAsync(path, "v2"); + await Task.Delay(5); + await service.CreateAsync(Root, Root, "second", [path]); + await fileSystem.WriteAllTextAsync(path, "v3"); + + var restored = await service.RestoreLatestAsync(Root); + + restored!.Operation.ShouldBe("second"); + fileSystem.ReadText(path).ShouldBe("v2"); + } + + [Fact] + public async Task List_ShouldReturnBackupsNewestFirst() + { + var path = Path.Combine(Root, "App.csproj"); + var fileSystem = new InMemoryFileSystem().AddFile(path, "x"); + var service = new BackupService(fileSystem); + + await service.CreateAsync(Root, Root, "first", [path]); + await Task.Delay(5); + await service.CreateAsync(Root, Root, "second", [path]); + + var backups = service.List(Root); + + backups.Count.ShouldBe(2); + backups[0].Operation.ShouldBe("second"); + } + + [Fact] + public async Task Prune_ShouldKeepOnlyTheRetainedBackups() + { + var path = Path.Combine(Root, "App.csproj"); + var fileSystem = new InMemoryFileSystem().AddFile(path, "x"); + var service = new BackupService(fileSystem); + + for (var i = 0; i < 4; i++) + { + await service.CreateAsync(Root, Root, $"op{i}", [path]); + await Task.Delay(5); + } + + var removed = service.Prune(Root, retention: 2); + + removed.Count.ShouldBe(2); + service.List(Root).Count.ShouldBe(2); + } + + [Fact] + public async Task PruneAll_ShouldRemoveEverything() + { + var path = Path.Combine(Root, "App.csproj"); + var fileSystem = new InMemoryFileSystem().AddFile(path, "x"); + var service = new BackupService(fileSystem); + + await service.CreateAsync(Root, Root, "op", [path]); + + service.PruneAll(Root); + + service.List(Root).ShouldBeEmpty(); + } + + [Fact] + public async Task EnsureGitIgnoreAsync_ShouldAppendTheBackupFolder() + { + var fileSystem = new InMemoryFileSystem().AddFile(Path.Combine(Root, ".gitignore"), "bin/\n"); + var service = new BackupService(fileSystem); + + await service.EnsureGitIgnoreAsync(Root, BackupService.DefaultBackupFolderName); + + var content = fileSystem.ReadText(Path.Combine(Root, ".gitignore")); + content.ShouldContain("bin/"); + content.ShouldContain(".centralconfig-backups/"); + } + + [Fact] + public async Task EnsureGitIgnoreAsync_ShouldNotDuplicateAnExistingEntry() + { + var path = Path.Combine(Root, ".gitignore"); + var fileSystem = new InMemoryFileSystem().AddFile(path, ".centralconfig-backups/\n"); + var service = new BackupService(fileSystem); + + await service.EnsureGitIgnoreAsync(Root, BackupService.DefaultBackupFolderName); + + fileSystem.ReadText(path).Split(".centralconfig-backups/").Length.ShouldBe(2); + } +} diff --git a/CentralConfigGenerator.Core.Tests/Services/UnifiedDiffTests.cs b/CentralConfigGenerator.Core.Tests/Services/UnifiedDiffTests.cs new file mode 100644 index 0000000..5337c3b --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Services/UnifiedDiffTests.cs @@ -0,0 +1,42 @@ +using CentralConfigGenerator.Core.Services; + +namespace CentralConfigGenerator.Core.Tests.Services; + +public class UnifiedDiffTests +{ + [Fact] + public void Create_ShouldReturnEmptyForIdenticalInput() => + UnifiedDiff.Create("same\ntext", "same\ntext", "a", "b").ShouldBeEmpty(); + + [Fact] + public void Create_ShouldMarkAddedAndRemovedLines() + { + var diff = UnifiedDiff.Create("one\ntwo\nthree", "one\nTWO\nthree", "a.txt", "b.txt"); + + diff.ShouldContain("--- a.txt"); + diff.ShouldContain("+++ b.txt"); + diff.ShouldContain("-two"); + diff.ShouldContain("+TWO"); + diff.ShouldContain(" one"); + } + + [Fact] + public void Create_ShouldIncludeAHunkHeader() => + UnifiedDiff.Create("a", "b", "x", "y").ShouldContain("@@"); + + [Fact] + public void Create_ShouldHandlePureInsertion() + { + var diff = UnifiedDiff.Create(string.Empty, "new line", "/dev/null", "b.txt"); + + diff.ShouldContain("+new line"); + } + + [Fact] + public void Create_ShouldNormaliseWindowsLineEndings() + { + var diff = UnifiedDiff.Create("a\r\nb", "a\nb", "x", "y"); + + diff.ShouldBeEmpty(); + } +} diff --git a/CentralConfigGenerator.Core.Tests/Services/VersionSelectorTests.cs b/CentralConfigGenerator.Core.Tests/Services/VersionSelectorTests.cs new file mode 100644 index 0000000..c10c978 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Services/VersionSelectorTests.cs @@ -0,0 +1,100 @@ +using CentralConfigGenerator.Core.Services; + +namespace CentralConfigGenerator.Core.Tests.Services; + +public class VersionSelectorTests +{ + private readonly VersionSelector _selector = new(new VersionConflictResolver()); + + [Fact] + public void Select_ShouldReturnTheOnlyVersion() => + _selector + .Select("Pkg", ["1.0.0"], VersionResolutionStrategy.Highest, false) + .ShouldBe("1.0.0"); + + [Fact] + public void Select_ShouldPickTheHighestVersion() => + _selector + .Select("Pkg", ["1.0.0", "2.1.0", "2.0.0"], VersionResolutionStrategy.Highest, false) + .ShouldBe("2.1.0"); + + [Fact] + public void Select_ShouldPickTheLowestVersion() => + _selector + .Select("Pkg", ["1.0.0", "2.1.0"], VersionResolutionStrategy.Lowest, false) + .ShouldBe("1.0.0"); + + [Fact] + public void Select_ShouldDropPrereleasesWhenStableExists() => + _selector + .Select("Pkg", ["2.0.0-beta", "1.9.0"], VersionResolutionStrategy.Highest, true) + .ShouldBe("1.9.0"); + + [Fact] + public void Select_ShouldKeepPrereleasesWhenNoStableExists() => + _selector + .Select("Pkg", ["2.0.0-beta", "2.0.0-alpha"], VersionResolutionStrategy.Highest, true) + .ShouldBe("2.0.0-beta"); + + [Fact] + public void Select_ShouldThrowForTheFailStrategy() => + Should.Throw(() => + _selector.Select("Pkg", ["1.0.0", "2.0.0"], VersionResolutionStrategy.Fail, false) + ); + + [Fact] + public void Select_ShouldThrowWhenNoVersionsAreSupplied() => + Should.Throw(() => + _selector.Select("Pkg", [], VersionResolutionStrategy.Highest, false) + ); + + [Theory] + [InlineData("4.*", true)] + [InlineData("1.2.*", true)] + [InlineData("[4.0.0,)", true)] + [InlineData("1.2.3", false)] + [InlineData("[1.0.0,2.0.0)", false)] + public void IsFloating_ShouldDetectWildcardsAndOpenRanges(string version, bool expected) => + VersionSelector.IsFloating(version).ShouldBe(expected); + + [Fact] + public void Select_ShouldIgnorePrereleaseTagsUnderTheVersionComparisonScope() + { + // VersionComparison.Version compares only the numeric parts, so the release and the + // pre-release of the same number tie and the first candidate wins. + var chosen = _selector.Select( + "Pkg", + ["2.0.0-beta", "2.0.0"], + VersionResolutionStrategy.Highest, + ignorePrerelease: false, + global::NuGet.Versioning.VersionComparison.Version + ); + + chosen.ShouldBeOneOf("2.0.0", "2.0.0-beta"); + } + + [Fact] + public void Select_ShouldRankReleaseAbovePrereleaseUnderVersionRelease() => + _selector + .Select( + "Pkg", + ["2.0.0-beta", "2.0.0"], + VersionResolutionStrategy.Highest, + ignorePrerelease: false, + global::NuGet.Versioning.VersionComparison.VersionRelease + ) + .ShouldBe("2.0.0"); + + [Fact] + public void Select_ShouldFallBackToTheResolverForUnparseableVersions() => + _selector + .Select("Pkg", ["$(SomeProperty)", "$(Other)"], VersionResolutionStrategy.Highest, false) + .ShouldNotBeNullOrEmpty(); + + [Theory] + [InlineData("1.0.0-beta", true)] + [InlineData("1.0.0", false)] + [InlineData("not-a-version", false)] + public void IsPrerelease_ShouldDetectPrereleaseTags(string version, bool expected) => + VersionSelector.IsPrerelease(version).ShouldBe(expected); +} diff --git a/CentralConfigGenerator.Core.Tests/TestSupport/FakeDotNetCli.cs b/CentralConfigGenerator.Core.Tests/TestSupport/FakeDotNetCli.cs new file mode 100644 index 0000000..7a77352 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/TestSupport/FakeDotNetCli.cs @@ -0,0 +1,75 @@ +using CentralConfigGenerator.Core.Services; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Tests.TestSupport; + +/// +/// A scripted dotnet CLI. Tests decide which package versions make the "test run" pass, +/// which is what drives the bisect logic. +/// +public sealed class FakeDotNetCli(Func, bool> testsPass, Func readProps) + : IDotNetCliService +{ + public int RestoreCount { get; private set; } + + public int TestCount { get; private set; } + + public bool IsAvailable { get; set; } = true; + + public Task RunAsync( + string arguments, + string workingDirectory, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default + ) => Task.FromResult(Success()); + + public Task RestoreAsync( + string target, + string workingDirectory, + CancellationToken cancellationToken = default + ) + { + RestoreCount++; + return Task.FromResult(Success()); + } + + public Task BuildAsync( + string target, + string workingDirectory, + CancellationToken cancellationToken = default + ) => Task.FromResult(Success()); + + public Task TestAsync( + string target, + string workingDirectory, + string? filter = null, + CancellationToken cancellationToken = default + ) + { + TestCount++; + + var versions = MsBuildXml.TryParse(readProps(), out var document, out _) + ? PackageReferenceReader.ReadPackageVersions(document) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + + return Task.FromResult(testsPass(versions) ? Success() : Failure()); + } + + private static ProcessResult Success() => + new() + { + ExitCode = 0, + StandardOutput = string.Empty, + StandardError = string.Empty, + Duration = TimeSpan.Zero, + }; + + private static ProcessResult Failure() => + new() + { + ExitCode = 1, + StandardOutput = string.Empty, + StandardError = "tests failed", + Duration = TimeSpan.Zero, + }; +} diff --git a/CentralConfigGenerator.Core.Tests/TestSupport/FakeNuGetFeed.cs b/CentralConfigGenerator.Core.Tests/TestSupport/FakeNuGetFeed.cs new file mode 100644 index 0000000..2003e11 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/TestSupport/FakeNuGetFeed.cs @@ -0,0 +1,88 @@ +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.NuGet; +using NuGet.Versioning; + +namespace CentralConfigGenerator.Core.Tests.TestSupport; + +/// A scripted feed so the network-backed rules can be tested deterministically. +public sealed class FakeNuGetFeed : INuGetFeedService +{ + private readonly Dictionary _snapshots = + new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList Sources => ["https://fake/index.json"]; + + public FakeNuGetFeed Add( + string packageId, + string[]? versions = null, + bool deprecated = false, + string? alternate = null, + string? license = null, + (string Version, Severity Severity)[]? vulnerabilities = null + ) + { + var parsed = (versions ?? ["1.0.0"]).Select(NuGetVersion.Parse).OrderByDescending(v => v).ToList(); + + _snapshots[packageId] = new PackageMetadataSnapshot + { + PackageId = packageId, + Found = true, + AllVersions = parsed, + LatestStable = parsed.FirstOrDefault(v => !v.IsPrerelease), + LatestIncludingPrerelease = parsed.FirstOrDefault(), + IsDeprecated = deprecated, + DeprecationReasons = deprecated ? ["Legacy"] : [], + AlternatePackageId = alternate, + License = license, + Vulnerabilities = + vulnerabilities + ?.Select(v => new PackageVulnerability + { + Severity = v.Severity, + AdvisoryUrl = $"https://advisory/{packageId}", + AffectedRange = v.Version, + }) + .ToList() ?? [], + }; + + return this; + } + + public Task GetMetadataAsync( + string packageId, + CancellationToken cancellationToken = default + ) => + Task.FromResult( + _snapshots.TryGetValue(packageId, out var snapshot) + ? snapshot + : new PackageMetadataSnapshot { PackageId = packageId, Found = false } + ); + + public async Task> GetMetadataAsync( + IEnumerable packageIds, + int maxParallelism, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var id in packageIds) + { + result[id] = await GetMetadataAsync(id, cancellationToken); + } + + return result; + } +} + +/// A package graph service that returns whatever the test hands it. +public sealed class FakePackageGraphService(PackageGraph graph) : IPackageGraphService +{ + public Task LoadAsync( + string target, + string workingDirectory, + bool includeTransitive, + CancellationToken cancellationToken = default + ) => Task.FromResult(graph); +} diff --git a/CentralConfigGenerator.Core.Tests/TestSupport/InMemoryFileSystem.cs b/CentralConfigGenerator.Core.Tests/TestSupport/InMemoryFileSystem.cs new file mode 100644 index 0000000..c811c87 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/TestSupport/InMemoryFileSystem.cs @@ -0,0 +1,142 @@ +using System.Text; +using CentralConfigGenerator.Core.IO; + +namespace CentralConfigGenerator.Core.Tests.TestSupport; + +/// +/// An in-memory so the file-touching services can be tested without +/// going near a real disk. +/// +public sealed class InMemoryFileSystem : IFileSystem +{ + private readonly Dictionary _files = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _directories = new(StringComparer.OrdinalIgnoreCase); + + public InMemoryFileSystem AddFile(string path, string content) + { + var full = Normalize(path); + _files[full] = new UTF8Encoding(false).GetBytes(content); + AddParentDirectories(full); + return this; + } + + public InMemoryFileSystem AddFile(string path, byte[] bytes) + { + var full = Normalize(path); + _files[full] = bytes; + AddParentDirectories(full); + return this; + } + + public InMemoryFileSystem AddDirectory(string path) + { + var full = Normalize(path); + _directories.Add(full); + AddParentDirectories(full); + return this; + } + + public string ReadText(string path) => + new UTF8Encoding(false).GetString(_files[Normalize(path)]); + + public byte[] ReadBytes(string path) => _files[Normalize(path)]; + + public IReadOnlyCollection Files => _files.Keys; + + public bool FileExists(string path) => _files.ContainsKey(Normalize(path)); + + public bool DirectoryExists(string path) => _directories.Contains(Normalize(path)); + + public void CreateDirectory(string path) => AddDirectory(path); + + public void DeleteFile(string path) => _files.Remove(Normalize(path)); + + public void DeleteDirectory(string path, bool recursive) + { + var prefix = Normalize(path) + Path.DirectorySeparatorChar; + + foreach (var file in _files.Keys.Where(f => f.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)).ToList()) + { + _files.Remove(file); + } + + foreach (var directory in _directories.Where(d => d.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)).ToList()) + { + _directories.Remove(directory); + } + + _directories.Remove(Normalize(path)); + } + + public IReadOnlyList EnumerateFiles(string path, string searchPattern, bool recursive) + { + var prefix = Normalize(path) + Path.DirectorySeparatorChar; + + return _files + .Keys.Where(f => f.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Where(f => + recursive + || !f[prefix.Length..].Contains(Path.DirectorySeparatorChar) + ) + .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public IReadOnlyList EnumerateDirectories(string path) + { + var prefix = Normalize(path) + Path.DirectorySeparatorChar; + + return _directories + .Where(d => d.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Where(d => !d[prefix.Length..].Contains(Path.DirectorySeparatorChar)) + .OrderBy(d => d, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public Task ReadAllBytesAsync(string path, CancellationToken cancellationToken = default) => + Task.FromResult(_files[Normalize(path)]); + + public Task WriteAllBytesAsync(string path, byte[] bytes, CancellationToken cancellationToken = default) + { + var full = Normalize(path); + _files[full] = bytes; + AddParentDirectories(full); + return Task.CompletedTask; + } + + public Task ReadAllTextAsync(string path, CancellationToken cancellationToken = default) => + Task.FromResult(ReadText(path)); + + public Task WriteAllTextAsync(string path, string contents, CancellationToken cancellationToken = default) + { + AddFile(path, contents); + return Task.CompletedTask; + } + + public void CopyFile(string source, string destination, bool overwrite) + { + var full = Normalize(destination); + _files[full] = _files[Normalize(source)]; + AddParentDirectories(full); + } + + public DateTimeOffset GetLastWriteTime(string path) => DateTimeOffset.UtcNow; + + public long GetFileSize(string path) => _files[Normalize(path)].Length; + + private static string Normalize(string path) => + Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar); + + private void AddParentDirectories(string path) + { + // Every ancestor must be registered, even if a sibling already registered one of them. + for ( + var directory = Path.GetDirectoryName(path); + !string.IsNullOrEmpty(directory); + directory = Path.GetDirectoryName(directory) + ) + { + _directories.Add(directory); + } + } +} diff --git a/CentralConfigGenerator.Core.Tests/TestSupport/Sample.cs b/CentralConfigGenerator.Core.Tests/TestSupport/Sample.cs new file mode 100644 index 0000000..7de402a --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/TestSupport/Sample.cs @@ -0,0 +1,46 @@ +namespace CentralConfigGenerator.Core.Tests.TestSupport; + +/// Reusable project XML fixtures. +public static class Sample +{ + public const string AppProject = """ + + + Exe + net8.0 + enable + + + + + + + """; + + public const string LibProject = """ + + + net8.0 + enable + + + + + 2.12.0 + + + + """; + + public const string PackagesProps = """ + + + true + + + + + + + """; +} diff --git a/CentralConfigGenerator.Core.Tests/Workspace/BatchServiceTests.cs b/CentralConfigGenerator.Core.Tests/Workspace/BatchServiceTests.cs new file mode 100644 index 0000000..d708f80 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Workspace/BatchServiceTests.cs @@ -0,0 +1,121 @@ +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Tests.TestSupport; +using CentralConfigGenerator.Core.Workspace; + +namespace CentralConfigGenerator.Core.Tests.Workspace; + +public class BatchServiceTests +{ + private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-batch"); + + [Fact] + public void DiscoverWorkspaces_ShouldReturnOneDirectoryPerSolution() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "repo-a", "A.sln"), "sln") + .AddFile(Path.Combine(Root, "repo-b", "B.slnx"), ""); + + var workspaces = new BatchService(fileSystem).DiscoverWorkspaces( + new BatchOptions { RootDirectory = Root } + ); + + workspaces.Count.ShouldBe(2); + } + + [Fact] + public void DiscoverWorkspaces_ShouldFallBackToProjectDirectories() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "repo-a", "A.csproj"), Sample.AppProject); + + var workspaces = new BatchService(fileSystem).DiscoverWorkspaces( + new BatchOptions { RootDirectory = Root } + ); + + workspaces.ShouldHaveSingleItem(); + } + + [Fact] + public void DiscoverWorkspaces_ShouldRespectMaxDepth() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "a", "b", "c", "d", "Deep.sln"), "sln"); + + var workspaces = new BatchService(fileSystem).DiscoverWorkspaces( + new BatchOptions { RootDirectory = Root, MaxDepth = 2 } + ); + + workspaces.ShouldBeEmpty(); + } + + [Fact] + public void DiscoverWorkspaces_ShouldSkipBuildOutput() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "repo", "A.sln"), "sln") + .AddFile(Path.Combine(Root, "repo", "bin", "Copy.sln"), "sln"); + + var workspaces = new BatchService(fileSystem).DiscoverWorkspaces( + new BatchOptions { RootDirectory = Root } + ); + + workspaces.ShouldHaveSingleItem(); + } + + [Fact] + public async Task RunAsync_ShouldProcessEveryWorkspace() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "repo-a", "A.sln"), "sln") + .AddFile(Path.Combine(Root, "repo-b", "B.sln"), "sln"); + + var result = await new BatchService(fileSystem).RunAsync( + new BatchOptions { RootDirectory = Root }, + (_, _) => Task.FromResult(EmptyPlan()) + ); + + result.SucceededCount.ShouldBe(2); + result.FailedCount.ShouldBe(0); + } + + [Fact] + public async Task RunAsync_ShouldStopAtTheFirstFailureByDefault() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "repo-a", "A.sln"), "sln") + .AddFile(Path.Combine(Root, "repo-b", "B.sln"), "sln"); + + var result = await new BatchService(fileSystem).RunAsync( + new BatchOptions { RootDirectory = Root }, + (_, _) => throw new InvalidOperationException("boom") + ); + + result.Items.Count.ShouldBe(1); + result.Items[0].Error.ShouldBe("boom"); + } + + [Fact] + public async Task RunAsync_ShouldKeepGoingWhenContinueOnErrorIsSet() + { + var fileSystem = new InMemoryFileSystem() + .AddFile(Path.Combine(Root, "repo-a", "A.sln"), "sln") + .AddFile(Path.Combine(Root, "repo-b", "B.sln"), "sln"); + + var result = await new BatchService(fileSystem).RunAsync( + new BatchOptions { RootDirectory = Root, ContinueOnError = true }, + (_, _) => throw new InvalidOperationException("boom") + ); + + result.FailedCount.ShouldBe(2); + } + + private static MigrationPlan EmptyPlan() => + new() + { + Changes = [], + ResolvedVersions = new Dictionary(), + Conflicts = [], + Findings = [], + ProjectCount = 0, + }; +} diff --git a/CentralConfigGenerator.Core.Tests/Workspace/DependencyTreeServiceTests.cs b/CentralConfigGenerator.Core.Tests/Workspace/DependencyTreeServiceTests.cs new file mode 100644 index 0000000..9b21c6a --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Workspace/DependencyTreeServiceTests.cs @@ -0,0 +1,80 @@ +using CentralConfigGenerator.Core.NuGet; +using CentralConfigGenerator.Core.Workspace; + +namespace CentralConfigGenerator.Core.Tests.Workspace; + +public class DependencyTreeServiceTests +{ + private static readonly PackageGraph Graph = new() + { + IsAvailable = true, + Packages = + [ + new ResolvedPackage + { + PackageId = "Serilog", + ResolvedVersion = "3.1.1", + RequestedVersion = "3.1.1", + ProjectPath = "/repo/src/App/App.csproj", + TargetFramework = "net8.0", + }, + new ResolvedPackage + { + PackageId = "System.Text.Json", + ResolvedVersion = "8.0.0", + ProjectPath = "/repo/src/App/App.csproj", + TargetFramework = "net8.0", + IsTransitive = true, + }, + ], + }; + + [Fact] + public void Render_ShouldDrawProjectFrameworkAndPackages() + { + var tree = DependencyTreeService.Render(Graph, includeTransitive: true); + + tree.ShouldContain("App"); + tree.ShouldContain("net8.0"); + tree.ShouldContain("Serilog 3.1.1"); + tree.ShouldContain("System.Text.Json 8.0.0 (transitive)"); + } + + [Fact] + public void Render_ShouldOmitTransitivePackagesWhenNotRequested() + { + var tree = DependencyTreeService.Render(Graph, includeTransitive: false); + + tree.ShouldNotContain("System.Text.Json"); + } + + [Fact] + public void Render_ShouldNoteWhenTheResolvedVersionDiffersFromTheRequest() + { + var graph = Graph with + { + Packages = + [ + Graph.Packages[0] with { RequestedVersion = "3.0.0", ResolvedVersion = "3.1.1" }, + ], + }; + + DependencyTreeService.Render(graph, includeTransitive: false) + .ShouldContain("(requested 3.0.0)"); + } + + [Fact] + public async Task RenderAsync_ShouldExplainWhenTheGraphIsUnavailable() + { + var service = new DependencyTreeService( + new TestSupport.FakePackageGraphService( + PackageGraph.Empty with { Error = "restore failed" } + ) + ); + + var output = await service.RenderAsync("/repo", "/repo", includeTransitive: false); + + output.ShouldContain("Dependency graph unavailable"); + output.ShouldContain("restore failed"); + } +} diff --git a/CentralConfigGenerator.Core.Tests/Workspace/PackageGraphServiceTests.cs b/CentralConfigGenerator.Core.Tests/Workspace/PackageGraphServiceTests.cs new file mode 100644 index 0000000..b6b598c --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Workspace/PackageGraphServiceTests.cs @@ -0,0 +1,105 @@ +using CentralConfigGenerator.Core.NuGet; + +namespace CentralConfigGenerator.Core.Tests.Workspace; + +public class PackageGraphServiceTests +{ + private const string SampleJson = """ + { + "version": 1, + "projects": [ + { + "path": "/repo/src/App/App.csproj", + "frameworks": [ + { + "framework": "net8.0", + "topLevelPackages": [ + { "id": "Serilog", "requestedVersion": "3.1.1", "resolvedVersion": "3.1.1" } + ], + "transitivePackages": [ + { "id": "System.Text.Json", "resolvedVersion": "8.0.0" } + ] + } + ] + } + ] + } + """; + + [Fact] + public void Parse_ShouldReadDirectAndTransitivePackages() + { + var graph = PackageGraphService.Parse(SampleJson); + + graph.IsAvailable.ShouldBeTrue(); + graph.Direct.Count().ShouldBe(1); + graph.Transitive.Count().ShouldBe(1); + graph.Packages[0].TargetFramework.ShouldBe("net8.0"); + } + + [Fact] + public void Parse_ShouldToleratePrefixNoiseBeforeTheJson() + { + var graph = PackageGraphService.Parse("Restoring...\n" + SampleJson); + + graph.IsAvailable.ShouldBeTrue(); + } + + [Fact] + public void Parse_ShouldReportAnErrorForNonJson() + { + var graph = PackageGraphService.Parse("no json here"); + + graph.IsAvailable.ShouldBeFalse(); + graph.Error.ShouldNotBeNull(); + } + + [Fact] + public void Parse_ShouldReportAnErrorForMalformedJson() + { + var graph = PackageGraphService.Parse("{ \"projects\": [ "); + + graph.IsAvailable.ShouldBeFalse(); + } + + [Fact] + public void DivergentPackages_ShouldFindVersionSplits() + { + var graph = new PackageGraph + { + IsAvailable = true, + Packages = + [ + Package("A", "1.0.0", "One.csproj"), + Package("A", "2.0.0", "Two.csproj"), + Package("B", "1.0.0", "One.csproj"), + ], + }; + + graph.DivergentPackages.Select(g => g.Key).ShouldBe(["A"]); + } + + [Fact] + public void Signature_ShouldKeyByProjectFrameworkAndPackage() + { + var graph = new PackageGraph + { + IsAvailable = true, + Packages = [Package("A", "1.0.0", "/repo/One.csproj")], + }; + + var signature = graph.Signature(); + + signature.ShouldContainKey("One.csproj|net8.0|A"); + signature["One.csproj|net8.0|A"].ShouldBe("1.0.0"); + } + + private static ResolvedPackage Package(string id, string version, string project) => + new() + { + PackageId = id, + ResolvedVersion = version, + ProjectPath = project, + TargetFramework = "net8.0", + }; +} diff --git a/CentralConfigGenerator.Core.Tests/Workspace/PackageUpdateServiceTests.cs b/CentralConfigGenerator.Core.Tests/Workspace/PackageUpdateServiceTests.cs new file mode 100644 index 0000000..8cf3acb --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Workspace/PackageUpdateServiceTests.cs @@ -0,0 +1,204 @@ +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.Services; +using CentralConfigGenerator.Core.Tests.TestSupport; +using CentralConfigGenerator.Core.Workspace; + +namespace CentralConfigGenerator.Core.Tests.Workspace; + +public class PackageUpdateServiceTests +{ + private static readonly string Root = Path.Combine(Path.GetTempPath(), "ccg-update"); + private static readonly string PropsPath = Path.Combine(Root, "Directory.Packages.props"); + + private const string Props = """ + + + true + + + + + + + + + """; + + private static FakeNuGetFeed Feed() => + new FakeNuGetFeed() + .Add("Alpha", ["1.0.0", "2.0.0"]) + .Add("Beta", ["1.0.0", "2.0.0"]) + .Add("Gamma", ["1.0.0", "2.0.0"]) + .Add("Delta", ["1.0.0", "2.0.0"]); + + [Fact] + public async Task UpdateAsync_ShouldReportCandidatesWithoutWritingOnDryRun() + { + var (service, fileSystem, _) = Create(_ => true); + + var result = await service.UpdateAsync(Options() with { DryRun = true }); + + result.Candidates.Count.ShouldBe(4); + result.Applied.ShouldBeEmpty(); + fileSystem.ReadText(PropsPath).ShouldBe(Props); + } + + [Fact] + public async Task UpdateAsync_ShouldApplyEverythingWhenTestsPass() + { + var (service, fileSystem, cli) = Create(_ => true); + + var result = await service.UpdateAsync(Options()); + + result.Applied.Count.ShouldBe(4); + result.TestsPassed.ShouldBeTrue(); + result.RolledBack.ShouldBeFalse(); + fileSystem.ReadText(PropsPath).ShouldContain("Include=\"Alpha\" Version=\"2.0.0\""); + cli.TestCount.ShouldBe(1); + } + + [Fact] + public async Task UpdateAsync_ShouldRollEverythingBackWhenTestsFail() + { + var (service, fileSystem, _) = Create(_ => false); + + var result = await service.UpdateAsync(Options()); + + result.Applied.ShouldBeEmpty(); + result.RolledBack.ShouldBeTrue(); + result.HeldBack.Count.ShouldBe(4); + fileSystem.ReadText(PropsPath).ShouldBe(Props); + } + + [Fact] + public async Task UpdateAsync_ShouldKeepTheGreenSubsetWhenBisecting() + { + // Gamma is the poison pill: any set containing it fails. + var (service, fileSystem, _) = Create(versions => versions["Gamma"] == "1.0.0"); + + var result = await service.UpdateAsync(Options() with { Bisect = true }); + + result.TestsPassed.ShouldBeTrue(); + result.RolledBack.ShouldBeFalse(); + result.Applied.ShouldNotBeEmpty(); + result.Applied.ShouldNotContain(c => c.PackageId == "Gamma"); + result.HeldBack.ShouldContain(c => c.PackageId == "Gamma"); + fileSystem.ReadText(PropsPath).ShouldContain("Include=\"Gamma\" Version=\"1.0.0\""); + } + + [Fact] + public async Task UpdateAsync_ShouldNameWhatItHeldBack() + { + var (service, _, _) = Create(versions => versions["Gamma"] == "1.0.0"); + + var result = await service.UpdateAsync(Options() with { Bisect = true }); + + result.Message.ShouldNotBeNull().ShouldContain("Gamma"); + } + + [Fact] + public async Task UpdateAsync_ShouldRollBackWhenNoSubsetPasses() + { + var (service, fileSystem, _) = Create(_ => false); + + var result = await service.UpdateAsync(Options() with { Bisect = true }); + + result.RolledBack.ShouldBeTrue(); + fileSystem.ReadText(PropsPath).ShouldBe(Props); + } + + [Fact] + public async Task UpdateAsync_ShouldRespectTheBisectBudget() + { + var (service, _, cli) = Create(versions => versions["Gamma"] == "1.0.0"); + + await service.UpdateAsync(Options() with { Bisect = true, BisectBudget = 3 }); + + cli.TestCount.ShouldBeLessThanOrEqualTo(4); + } + + [Fact] + public async Task UpdateAsync_ShouldRestrictToTheOnlyList() + { + var (service, _, _) = Create(_ => true); + + var result = await service.UpdateAsync(Options() with { Only = ["Alpha"] }); + + result.Candidates.ShouldHaveSingleItem(); + result.Candidates[0].PackageId.ShouldBe("Alpha"); + } + + [Fact] + public async Task UpdateAsync_ShouldSkipTestsWhenAsked() + { + var (service, fileSystem, cli) = Create(_ => false); + + var result = await service.UpdateAsync(Options() with { SkipTests = true }); + + cli.TestCount.ShouldBe(0); + result.Applied.Count.ShouldBe(4); + fileSystem.ReadText(PropsPath).ShouldContain("2.0.0"); + } + + [Fact] + public async Task UpdateAsync_ShouldExplainWhenThereIsNoPropsFile() + { + var fileSystem = new InMemoryFileSystem().AddDirectory(Root); + var service = Build(fileSystem, new FakeDotNetCli(_ => true, () => string.Empty)); + + var result = await service.UpdateAsync(Options()); + + result.Candidates.ShouldBeEmpty(); + result.Message.ShouldNotBeNull().ShouldContain("No Directory.Packages.props"); + } + + [Fact] + public async Task UpdateAsync_ShouldSayWhenEverythingIsCurrent() + { + var fileSystem = new InMemoryFileSystem().AddFile(PropsPath, Props); + var feed = new FakeNuGetFeed() + .Add("Alpha", ["1.0.0"]) + .Add("Beta", ["1.0.0"]) + .Add("Gamma", ["1.0.0"]) + .Add("Delta", ["1.0.0"]); + + var service = new PackageUpdateService( + new ProjectDiscoveryService(fileSystem), + fileSystem, + feed, + new FakeDotNetCli(_ => true, () => fileSystem.ReadText(PropsPath)), + new BackupService(fileSystem) + ); + + var result = await service.UpdateAsync(Options()); + + result.Message.ShouldNotBeNull().ShouldContain("up to date"); + } + + private static PackageUpdateOptions Options() => + new() + { + RootDirectory = Root, + PackagesPropsPath = PropsPath, + BackupDirectory = Root, + }; + + private static (PackageUpdateService Service, InMemoryFileSystem FileSystem, FakeDotNetCli Cli) Create( + Func, bool> testsPass + ) + { + var fileSystem = new InMemoryFileSystem().AddFile(PropsPath, Props); + var cli = new FakeDotNetCli(testsPass, () => fileSystem.ReadText(PropsPath)); + + return (Build(fileSystem, cli), fileSystem, cli); + } + + private static PackageUpdateService Build(InMemoryFileSystem fileSystem, FakeDotNetCli cli) => + new( + new ProjectDiscoveryService(fileSystem), + fileSystem, + Feed(), + cli, + new BackupService(fileSystem) + ); +} diff --git a/CentralConfigGenerator.Core.Tests/Workspace/VerificationServiceTests.cs b/CentralConfigGenerator.Core.Tests/Workspace/VerificationServiceTests.cs new file mode 100644 index 0000000..b51b703 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Workspace/VerificationServiceTests.cs @@ -0,0 +1,76 @@ +using CentralConfigGenerator.Core.NuGet; +using CentralConfigGenerator.Core.Services; +using CentralConfigGenerator.Core.Workspace; + +namespace CentralConfigGenerator.Core.Tests.Workspace; + +public class VerificationServiceTests +{ + private readonly VerificationService _service = new( + new TestSupport.FakePackageGraphService(PackageGraph.Empty), + new DotNetCliService() + ); + + [Fact] + public void Compare_ShouldReportNoDriftForIdenticalGraphs() + { + var graph = Graph(("A", "1.0.0")); + + var result = _service.Compare(graph, graph); + + result.IsClean.ShouldBeTrue(); + result.GraphAvailable.ShouldBeTrue(); + } + + [Fact] + public void Compare_ShouldReportChangedVersions() + { + var result = _service.Compare(Graph(("A", "1.0.0")), Graph(("A", "2.0.0"))); + + result.Drift.ShouldHaveSingleItem(); + result.Drift[0].Before.ShouldBe("1.0.0"); + result.Drift[0].After.ShouldBe("2.0.0"); + } + + [Fact] + public void Compare_ShouldReportAdditions() + { + var result = _service.Compare(Graph(("A", "1.0.0")), Graph(("A", "1.0.0"), ("B", "1.0.0"))); + + result.Drift.ShouldHaveSingleItem(); + result.Drift[0].IsAddition.ShouldBeTrue(); + } + + [Fact] + public void Compare_ShouldReportRemovals() + { + var result = _service.Compare(Graph(("A", "1.0.0"), ("B", "1.0.0")), Graph(("A", "1.0.0"))); + + result.Drift.ShouldHaveSingleItem(); + result.Drift[0].IsRemoval.ShouldBeTrue(); + } + + [Fact] + public void Compare_ShouldSurfaceUnavailableGraphs() + { + var result = _service.Compare(PackageGraph.Empty, Graph(("A", "1.0.0"))); + + result.GraphAvailable.ShouldBeFalse(); + result.Error.ShouldNotBeNull(); + } + + private static PackageGraph Graph(params (string Id, string Version)[] packages) => + new() + { + IsAvailable = true, + Packages = packages + .Select(p => new ResolvedPackage + { + PackageId = p.Id, + ResolvedVersion = p.Version, + ProjectPath = "/repo/App.csproj", + TargetFramework = "net8.0", + }) + .ToList(), + }; +} diff --git a/CentralConfigGenerator.Core.Tests/Xml/PackageReferenceReaderTests.cs b/CentralConfigGenerator.Core.Tests/Xml/PackageReferenceReaderTests.cs new file mode 100644 index 0000000..e38dbd5 --- /dev/null +++ b/CentralConfigGenerator.Core.Tests/Xml/PackageReferenceReaderTests.cs @@ -0,0 +1,117 @@ +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Tests.Xml; + +public class PackageReferenceReaderTests +{ + [Fact] + public void Read_ShouldPickUpAttributeVersions() + { + var project = Project(""" + + + + """); + + var references = PackageReferenceReader.Read(project); + + references.Count.ShouldBe(1); + references[0].PackageId.ShouldBe("Serilog"); + references[0].Version.ShouldBe("3.1.1"); + references[0].VersionIsElement.ShouldBeFalse(); + } + + [Fact] + public void Read_ShouldPickUpChildElementVersions() + { + var project = Project(""" + + + 2.12.0 + + + """); + + var references = PackageReferenceReader.Read(project); + + references[0].Version.ShouldBe("2.12.0"); + references[0].VersionIsElement.ShouldBeTrue(); + } + + [Fact] + public void Read_ShouldPickUpGlobalPackageReferences() + { + var project = Project(""" + + + + """); + + var references = PackageReferenceReader.Read(project); + + references[0].IsGlobal.ShouldBeTrue(); + } + + [Fact] + public void Read_ShouldCaptureInheritedConditions() + { + var project = Project(""" + + + + """); + + PackageReferenceReader.Read(project)[0].Condition.ShouldNotBeNull().ShouldContain("net8.0"); + } + + [Fact] + public void Read_ShouldReportPrivateAssets() + { + var project = Project(""" + + + + """); + + PackageReferenceReader.Read(project)[0].IsPrivateAssets.ShouldBeTrue(); + } + + [Fact] + public void Read_ShouldReturnEmptyForUnparseableXml() + { + var project = new ProjectFile { Path = "Broken.csproj", Content = "" }; + + PackageReferenceReader.Read(project).ShouldBeEmpty(); + } + + [Fact] + public void Read_ShouldTreatVersionlessReferencesAsNullVersion() + { + var project = Project(""" + + + + """); + + PackageReferenceReader.Read(project)[0].Version.ShouldBeNull(); + } + + [Fact] + public void ReadPackageVersions_ShouldReadCentralEntries() + { + var document = MsBuildXml.Parse(TestSupport.Sample.PackagesProps); + + var versions = PackageReferenceReader.ReadPackageVersions(document); + + versions.Count.ShouldBe(2); + versions["newtonsoft.json"].ShouldBe("13.0.3"); + } + + private static ProjectFile Project(string body) => + new() + { + Path = Path.Combine(Path.GetTempPath(), "Test.csproj"), + Content = $"\n{body}\n", + }; +} diff --git a/CentralConfigGenerator.Core/Analysis/AnalysisContext.cs b/CentralConfigGenerator.Core/Analysis/AnalysisContext.cs new file mode 100644 index 0000000..0277578 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/AnalysisContext.cs @@ -0,0 +1,62 @@ +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.NuGet; + +namespace CentralConfigGenerator.Core.Analysis; + +/// Everything the rules read from. Built once per run, then shared by all analyzers. +public sealed record AnalysisContext +{ + public required AnalysisOptions Options { get; init; } + + public required IReadOnlyList Projects { get; init; } + + public required IReadOnlyList References { get; init; } + + /// Versions declared in Directory.Packages.props, empty when the file is absent. + public required IReadOnlyDictionary CentralVersions { get; init; } + + public bool CentralPackageManagementEnabled { get; init; } + + public bool TransitivePinningEnabled { get; init; } + + public string? PackagesPropsPath { get; init; } + + public IReadOnlyDictionary Metadata { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public PackageGraph Graph { get; init; } = PackageGraph.Empty; + + /// Target frameworks declared per project, used by the framework alignment rule. + public IReadOnlyDictionary> TargetFrameworks { get; init; } = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + + public IEnumerable RelevantReferences => + References.Where(r => !IsIgnored(r.PackageId)); + + public bool IsIgnored(string packageId) => + Options.IgnorePackages.Any(pattern => + pattern.EndsWith('*') + ? packageId.StartsWith(pattern[..^1], StringComparison.OrdinalIgnoreCase) + : string.Equals(pattern, packageId, StringComparison.OrdinalIgnoreCase) + ); + + public PackageMetadataSnapshot? MetadataFor(string packageId) => + Metadata.TryGetValue(packageId, out var snapshot) ? snapshot : null; + + /// The version a package effectively resolves to: central entry first, then inline. + public string? EffectiveVersion(string packageId) + { + if (CentralVersions.TryGetValue(packageId, out var central)) + { + return central; + } + + return References + .Where(r => + string.Equals(r.PackageId, packageId, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(r.Version) + ) + .Select(r => r.Version) + .FirstOrDefault(); + } +} diff --git a/CentralConfigGenerator.Core/Analysis/AnalysisEngine.cs b/CentralConfigGenerator.Core/Analysis/AnalysisEngine.cs new file mode 100644 index 0000000..e02c6d0 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/AnalysisEngine.cs @@ -0,0 +1,353 @@ +using System.Diagnostics; +using System.Xml.Linq; +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.NuGet; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Analysis; + +public interface IAnalysisEngine +{ + Task AnalyzeAsync( + AnalysisOptions options, + IProgress? progress = null, + CancellationToken cancellationToken = default + ); + + Task BuildContextAsync( + AnalysisOptions options, + IProgress? progress = null, + CancellationToken cancellationToken = default + ); +} + +/// +/// Builds the analysis context once, runs every enabled rule against it, then applies severity +/// overrides and the baseline. +/// +public sealed class AnalysisEngine( + IProjectDiscoveryService discovery, + IFileSystem fileSystem, + INuGetFeedService feedService, + IPackageGraphService graphService, + IBaselineService baselineService +) : IAnalysisEngine +{ + public async Task AnalyzeAsync( + AnalysisOptions options, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + var stopwatch = Stopwatch.StartNew(); + var context = await BuildContextAsync(options, progress, cancellationToken); + + if (context.Projects.Count == 0) + { + return new AnalysisReport + { + Findings = [], + ProjectCount = 0, + PackageCount = 0, + Duration = stopwatch.Elapsed, + RootDirectory = options.RootDirectory, + }; + } + + var findings = new List(); + var incompletions = new List(); + + foreach (var rule in RuleCatalog.CreateAll()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!IsRuleActive(rule, options, context)) + { + continue; + } + + progress?.Report(rule.RuleId); + + try + { + foreach (var finding in rule.Analyze(context)) + { + findings.Add(ApplySeverityOverride(finding, options)); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + incompletions.Add($"{rule.RuleId} failed: {ex.Message}"); + } + } + + if (options.RequiresNetwork) + { + var unreachable = context + .Metadata.Values.Where(m => m.Error is not null) + .Select(m => $"{m.PackageId}: {m.Error}") + .ToList(); + + if (unreachable.Count > 0) + { + incompletions.Add( + $"{unreachable.Count} package(s) could not be checked against the feed." + ); + } + } + + if (options.Transitive && !context.Graph.IsAvailable) + { + incompletions.Add( + $"Transitive graph unavailable: {context.Graph.Error ?? "unknown reason"}." + ); + } + + var baseline = options.BaselinePath is null + ? null + : await baselineService.LoadAsync(options.BaselinePath, cancellationToken); + + var filtered = baselineService.Apply(findings, baseline); + + var ordered = filtered + .OrderByDescending(f => f.Severity) + .ThenBy(f => f.RuleId, StringComparer.Ordinal) + .ThenBy(f => f.PackageId ?? string.Empty, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return new AnalysisReport + { + Findings = ordered, + ProjectCount = context.Projects.Count, + PackageCount = context + .References.Select(r => r.PackageId) + .Concat(context.CentralVersions.Keys) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(), + Duration = stopwatch.Elapsed, + IsIncomplete = incompletions.Count > 0, + Incompletions = incompletions, + RootDirectory = options.RootDirectory, + SuppressedCount = findings.Count - filtered.Count, + }; + } + + public async Task BuildContextAsync( + AnalysisOptions options, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + progress?.Report("Discovering projects"); + + var projects = await discovery.DiscoverAsync( + new DiscoveryOptions + { + RootDirectory = options.RootDirectory, + SolutionPath = options.SolutionPath, + ProjectPath = options.ProjectPath, + ExcludePattern = options.ExcludePattern, + }, + cancellationToken + ); + + var references = projects.SelectMany(PackageReferenceReader.Read).ToList(); + var frameworks = ReadTargetFrameworks(projects); + + var (centralVersions, cpmEnabled, pinning, propsPath) = await ReadCentralFileAsync( + options, + cancellationToken + ); + + var graph = PackageGraph.Empty; + if (options.Transitive) + { + progress?.Report("Resolving transitive graph"); + graph = await graphService.LoadAsync( + options.SolutionPath ?? options.ProjectPath ?? options.RootDirectory, + options.RootDirectory, + includeTransitive: true, + cancellationToken + ); + } + + IReadOnlyDictionary metadata = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (options.RequiresNetwork) + { + var ids = references + .Select(r => r.PackageId) + .Concat(centralVersions.Keys) + .Concat(graph.Transitive.Select(p => p.PackageId)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + progress?.Report($"Querying {ids.Count} packages"); + + metadata = await feedService.GetMetadataAsync( + ids, + options.MaxParallelism, + progress, + cancellationToken + ); + } + + return new AnalysisContext + { + Options = options, + Projects = projects, + References = references, + CentralVersions = centralVersions, + CentralPackageManagementEnabled = cpmEnabled, + TransitivePinningEnabled = pinning, + PackagesPropsPath = propsPath, + Metadata = metadata, + Graph = graph, + TargetFrameworks = frameworks, + }; + } + + private static bool IsRuleActive( + IAnalysisRule rule, + AnalysisOptions options, + AnalysisContext context + ) + { + if ( + options.RuleOverrides.TryGetValue(rule.RuleId, out var severity) + && severity is null + ) + { + return false; + } + + if (!rule.IsEnabled(options)) + { + return false; + } + + if (rule.RequiresNetwork && !options.RequiresNetwork) + { + return false; + } + + return !rule.RequiresCentralPackageManagement || context.CentralPackageManagementEnabled; + } + + private static Finding ApplySeverityOverride(Finding finding, AnalysisOptions options) => + options.RuleOverrides.TryGetValue(finding.RuleId, out var severity) && severity is not null + ? finding with { Severity = severity.Value } + : finding; + + private async Task<( + IReadOnlyDictionary Versions, + bool CpmEnabled, + bool Pinning, + string? Path + )> ReadCentralFileAsync(AnalysisOptions options, CancellationToken cancellationToken) + { + var empty = new Dictionary(StringComparer.OrdinalIgnoreCase); + var path = FindPackagesProps(options); + + if (path is null) + { + return (empty, false, false, null); + } + + var file = await discovery.LoadAsync(path, cancellationToken); + if (file is null || !MsBuildXml.TryParse(file.Content, out var document, out _)) + { + return (empty, false, false, path); + } + + var versions = PackageReferenceReader.ReadPackageVersions(document); + + return ( + versions, + ReadBool(document, "ManagePackageVersionsCentrally"), + ReadBool(document, "CentralPackageTransitivePinningEnabled"), + path + ); + } + + private string? FindPackagesProps(AnalysisOptions options) + { + var start = + options.SolutionPath is not null + ? Path.GetDirectoryName(Path.GetFullPath(options.SolutionPath)) + : Path.GetFullPath(options.RootDirectory); + + var directory = start is null ? null : new DirectoryInfo(start); + + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, "Directory.Packages.props"); + if (fileSystem.FileExists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + return null; + } + + private static bool ReadBool(XDocument document, string propertyName) => + MsBuildXml + .Descendants(document, propertyName) + .Select(e => e.Value.Trim()) + .Any(v => string.Equals(v, "true", StringComparison.OrdinalIgnoreCase)); + + private static IReadOnlyDictionary> ReadTargetFrameworks( + IReadOnlyList projects + ) + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var project in projects) + { + if (!MsBuildXml.TryParse(project.Content, out var document, out _)) + { + continue; + } + + var frameworks = new List(); + + foreach (var element in MsBuildXml.Descendants(document, "TargetFramework")) + { + var value = element.Value.Trim(); + if (value.Length > 0 && !value.Contains('$')) + { + frameworks.Add(value); + } + } + + foreach (var element in MsBuildXml.Descendants(document, "TargetFrameworks")) + { + foreach ( + var value in element.Value.Split( + ';', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ) + ) + { + if (!value.Contains('$')) + { + frameworks.Add(value); + } + } + } + + if (frameworks.Count > 0) + { + result[project.Path] = frameworks.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + } + + return result; + } +} diff --git a/CentralConfigGenerator.Core/Analysis/AnalysisOptions.cs b/CentralConfigGenerator.Core/Analysis/AnalysisOptions.cs new file mode 100644 index 0000000..a60665e --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/AnalysisOptions.cs @@ -0,0 +1,45 @@ +namespace CentralConfigGenerator.Core.Analysis; + +/// Controls which analyzers run and how strict the run is. +public sealed record AnalysisOptions +{ + public string RootDirectory { get; init; } = Directory.GetCurrentDirectory(); + + public string? SolutionPath { get; init; } + + public string? ProjectPath { get; init; } + + public string? ExcludePattern { get; init; } + + /// Query the feed for known security advisories. + public bool Audit { get; init; } + + /// Report packages that are behind the newest published version. + public bool Outdated { get; init; } + + /// Report packages the author marked deprecated. + public bool Deprecated { get; init; } + + /// Flag copyleft and unknown licences. + public bool Licenses { get; init; } + + /// Resolve the transitive graph via dotnet list package. + public bool Transitive { get; init; } + + public int MaxParallelism { get; init; } = 8; + + public Severity FailOn { get; init; } = Severity.Info; + + /// Per-rule severity overrides. A null value disables the rule entirely. + public IReadOnlyDictionary RuleOverrides { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public string? BaselinePath { get; init; } + + public IReadOnlyCollection IgnorePackages { get; init; } = []; + + public bool IncludePrerelease { get; init; } + + /// True when any rule that needs the network is enabled. + public bool RequiresNetwork => Audit || Outdated || Deprecated || Licenses; +} diff --git a/CentralConfigGenerator.Core/Analysis/AnalysisReport.cs b/CentralConfigGenerator.Core/Analysis/AnalysisReport.cs new file mode 100644 index 0000000..fca659d --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/AnalysisReport.cs @@ -0,0 +1,71 @@ +namespace CentralConfigGenerator.Core.Analysis; + +/// Aggregated result of a dependency health analysis run. +public sealed record AnalysisReport +{ + public required IReadOnlyList Findings { get; init; } + + public required int ProjectCount { get; init; } + + public required int PackageCount { get; init; } + + public TimeSpan Duration { get; init; } + + /// True when part of the scan could not complete (for example the feed was unreachable). + public bool IsIncomplete { get; init; } + + public IReadOnlyList Incompletions { get; init; } = []; + + public string RootDirectory { get; init; } = string.Empty; + + public int SuppressedCount { get; init; } + + public Severity HighestSeverity => + Findings.Count == 0 ? Severity.Never : Findings.Max(f => f.Severity); + + public int CountOf(Severity severity) => Findings.Count(f => f.Severity == severity); + + /// + /// A 0-100 dependency health score. Every finding subtracts a weight scaled by severity, + /// so a clean workspace scores 100. + /// + public int HealthScore + { + get + { + if (PackageCount == 0) + { + return 100; + } + + double penalty = 0; + foreach (var finding in Findings) + { + penalty += finding.Severity switch + { + Severity.Critical => 25, + Severity.High => 12, + Severity.Moderate => 5, + Severity.Low => 2, + Severity.Info => 0.5, + _ => 0, + }; + } + + // Normalise against workspace size so large solutions are not punished twice. + var scale = Math.Max(1.0, Math.Sqrt(PackageCount) / 3.0); + var score = 100 - (penalty / scale); + return (int)Math.Round(Math.Clamp(score, 0, 100)); + } + } + + public string Grade => + HealthScore switch + { + >= 90 => "A", + >= 80 => "B", + >= 70 => "C", + >= 60 => "D", + _ => "F", + }; +} diff --git a/CentralConfigGenerator.Core/Analysis/AutoFixService.cs b/CentralConfigGenerator.Core/Analysis/AutoFixService.cs new file mode 100644 index 0000000..7b15ea2 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/AutoFixService.cs @@ -0,0 +1,267 @@ +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.Generators; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Services.Abstractions; + +namespace CentralConfigGenerator.Core.Analysis; + +/// The outcome of trying to auto-fix a set of findings. +public sealed record AutoFixResult +{ + public required IReadOnlyList Changes { get; init; } + + public required IReadOnlyList Fixed { get; init; } + + public required IReadOnlyList Skipped { get; init; } + + public BackupSet? Backup { get; init; } + + public bool HasChanges => Changes.Count > 0; +} + +public interface IAutoFixService +{ + Task PlanAsync( + AnalysisContext context, + IReadOnlyList findings, + CancellationToken cancellationToken = default + ); + + Task ApplyAsync( + AutoFixResult result, + string rootDirectory, + string? backupDirectory, + bool createBackup, + CancellationToken cancellationToken = default + ); +} + +/// +/// Turns auto-fixable findings into concrete file edits. Everything is planned in memory first so +/// --fix-dry-run and --fix share the same code path. +/// +public sealed class AutoFixService( + IProjectDiscoveryService discovery, + IFileSystem fileSystem, + IBackupService backupService +) : IAutoFixService +{ + public async Task PlanAsync( + AnalysisContext context, + IReadOnlyList findings, + CancellationToken cancellationToken = default + ) + { + // Working copies keyed by path, so several fixes can stack on the same file. + var buffers = new Dictionary( + StringComparer.OrdinalIgnoreCase + ); + + foreach (var project in context.Projects) + { + buffers[project.Path] = (project.Content, project.Content, project.Format); + } + + var propsPath = context.PackagesPropsPath; + if (propsPath is not null && !buffers.ContainsKey(propsPath)) + { + var props = await discovery.LoadAsync(propsPath, cancellationToken); + if (props is not null) + { + buffers[propsPath] = (props.Content, props.Content, props.Format); + } + } + + var fixedFindings = new List(); + var skipped = new List(); + + foreach (var finding in findings.OrderByDescending(f => f.Severity)) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!finding.IsAutoFixable) + { + skipped.Add(finding); + continue; + } + + if (TryApply(finding, buffers, propsPath)) + { + fixedFindings.Add(finding); + } + else + { + skipped.Add(finding); + } + } + + var changes = buffers + .Where(b => !string.Equals(b.Value.Original, b.Value.Current, StringComparison.Ordinal)) + .Select(b => new FileChange + { + Path = b.Key, + Kind = FileChangeKind.Modified, + OriginalContent = b.Value.Original, + NewContent = b.Value.Current, + Format = b.Value.Format, + }) + .OrderBy(c => c.Path, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return new AutoFixResult + { + Changes = changes, + Fixed = fixedFindings, + Skipped = skipped, + }; + } + + public async Task ApplyAsync( + AutoFixResult result, + string rootDirectory, + string? backupDirectory, + bool createBackup, + CancellationToken cancellationToken = default + ) + { + if (!result.HasChanges) + { + return null; + } + + BackupSet? backup = null; + + if (createBackup) + { + backup = await backupService.CreateAsync( + rootDirectory, + backupDirectory ?? rootDirectory, + "analyze --fix", + result.Changes.Select(c => c.Path), + cancellationToken + ); + } + + foreach (var change in result.Changes) + { + var bytes = EncodingDetector.Encode(change.NewContent, change.Format); + await fileSystem.WriteAllBytesAsync(change.Path, bytes, cancellationToken); + } + + return backup; + } + + private static bool TryApply( + Finding finding, + Dictionary buffers, + string? propsPath + ) + { + switch (finding.Fix) + { + case FixKind.RemoveInlineVersion: + return finding.ProjectPath is not null + && finding.PackageId is not null + && Edit( + buffers, + finding.ProjectPath, + content => + ProjectRewriter.StripVersion( + Wrap(finding.ProjectPath, content), + finding.PackageId + ) + ); + + case FixKind.SetCentralVersion: + return propsPath is not null + && finding.PackageId is not null + && finding.FixValue is not null + && Edit( + buffers, + propsPath, + content => + PackagesPropsEditor.SetVersion( + content, + finding.PackageId, + finding.FixValue + ) + ); + + case FixKind.RemoveOrphanedPackageVersion: + return propsPath is not null + && finding.PackageId is not null + && Edit( + buffers, + propsPath, + content => PackagesPropsEditor.RemoveEntry(content, finding.PackageId) + ); + + case FixKind.RemoveDuplicateReference: + return finding.ProjectPath is not null + && finding.PackageId is not null + && Edit( + buffers, + finding.ProjectPath, + content => + ProjectRewriter.RemoveDuplicateReference( + Wrap(finding.ProjectPath, content), + finding.PackageId + ) + ); + + case FixKind.NormalizePackageCasing: + { + if (finding.FixValue is null) + { + return false; + } + + var canonical = finding.FixValue; + var changed = false; + + foreach (var path in buffers.Keys.ToList()) + { + changed |= Edit( + buffers, + path, + content => + string.Equals(path, propsPath, StringComparison.OrdinalIgnoreCase) + ? PackagesPropsEditor.NormalizeCasing(content, canonical) + : ProjectRewriter.NormalizeCasing(Wrap(path, content), canonical) + ); + } + + return changed; + } + + default: + return false; + } + } + + private static ProjectFile Wrap(string path, string content) => + new() { Path = path, Content = content }; + + private static bool Edit( + Dictionary buffers, + string path, + Func transform + ) + { + if (!buffers.TryGetValue(path, out var buffer)) + { + return false; + } + + var updated = transform(buffer.Current); + if (updated is null) + { + return false; + } + + buffers[path] = (buffer.Original, updated, buffer.Format); + return true; + } +} diff --git a/CentralConfigGenerator.Core/Analysis/BaselineService.cs b/CentralConfigGenerator.Core/Analysis/BaselineService.cs new file mode 100644 index 0000000..6cf9bfb --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/BaselineService.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using CentralConfigGenerator.Core.IO; + +namespace CentralConfigGenerator.Core.Analysis; + +/// A recorded set of accepted findings, used to keep CI green while debt is paid down. +public sealed record Baseline +{ + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + + public List Fingerprints { get; init; } = []; +} + +public interface IBaselineService +{ + Task LoadAsync(string path, CancellationToken cancellationToken = default); + + Task WriteAsync( + string path, + IEnumerable findings, + CancellationToken cancellationToken = default + ); + + IReadOnlyList Apply(IReadOnlyList findings, Baseline? baseline); +} + +public sealed class BaselineService(IFileSystem fileSystem) : IBaselineService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public async Task LoadAsync( + string path, + CancellationToken cancellationToken = default + ) + { + if (!fileSystem.FileExists(path)) + { + return null; + } + + try + { + var json = await fileSystem.ReadAllTextAsync(path, cancellationToken); + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (JsonException) + { + return null; + } + } + + public async Task WriteAsync( + string path, + IEnumerable findings, + CancellationToken cancellationToken = default + ) + { + var baseline = new Baseline + { + Fingerprints = findings + .Select(f => f.Fingerprint) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToList(), + }; + + await fileSystem.WriteAllTextAsync( + path, + JsonSerializer.Serialize(baseline, JsonOptions), + cancellationToken + ); + } + + public IReadOnlyList Apply(IReadOnlyList findings, Baseline? baseline) + { + if (baseline is null || baseline.Fingerprints.Count == 0) + { + return findings; + } + + var accepted = baseline.Fingerprints.ToHashSet(StringComparer.Ordinal); + return findings.Where(f => !accepted.Contains(f.Fingerprint)).ToList(); + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Finding.cs b/CentralConfigGenerator.Core/Analysis/Finding.cs new file mode 100644 index 0000000..1b9c6dd --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Finding.cs @@ -0,0 +1,64 @@ +namespace CentralConfigGenerator.Core.Analysis; + +/// What an auto-fix should do when --fix is supplied. +public enum FixKind +{ + None = 0, + + /// Remove an inline Version attribute from a project's PackageReference. + RemoveInlineVersion, + + /// Set (or add) a PackageVersion entry in Directory.Packages.props. + SetCentralVersion, + + /// Delete a duplicate PackageReference element. + RemoveDuplicateReference, + + /// Rewrite a package id so its casing matches the canonical spelling. + NormalizePackageCasing, + + /// Remove an unused PackageVersion entry. + RemoveOrphanedPackageVersion, +} + +/// A single analysis result. +public sealed record Finding +{ + public required string RuleId { get; init; } + + public required Severity Severity { get; init; } + + public required string Message { get; init; } + + public string? PackageId { get; init; } + + public string? Version { get; init; } + + public string? ProjectPath { get; init; } + + /// + /// What the finding is about when it is not a package — an MSBuild property name, for + /// example. Keeps fingerprints unique for rules that report several findings per project. + /// + public string? Subject { get; init; } + + public int Line { get; init; } + + public string? Recommendation { get; init; } + + public FixKind Fix { get; init; } = FixKind.None; + + /// Target value for the fix (for example the version to pin to, or the canonical id). + public string? FixValue { get; init; } + + public bool IsAutoFixable => Fix != FixKind.None; + + /// Stable key used for baseline suppression. + public string Fingerprint => + $"{RuleId}|{PackageId ?? Subject ?? "-"}|{NormalizePath(ProjectPath)}|{Version ?? "-"}"; + + private static string NormalizePath(string? path) => + string.IsNullOrEmpty(path) + ? "-" + : Path.GetFileName(path); +} diff --git a/CentralConfigGenerator.Core/Analysis/IAnalysisRule.cs b/CentralConfigGenerator.Core/Analysis/IAnalysisRule.cs new file mode 100644 index 0000000..f5ceb7b --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/IAnalysisRule.cs @@ -0,0 +1,27 @@ +namespace CentralConfigGenerator.Core.Analysis; + +/// One dependency-health check. +public interface IAnalysisRule +{ + string RuleId { get; } + + string Title { get; } + + /// What the rule looks for, shown by the explain command. + string Description { get; } + + /// Why it matters and what to do about it. + string Rationale { get; } + + Severity DefaultSeverity { get; } + + /// True when the rule needs feed metadata to produce results. + bool RequiresNetwork => false; + + /// True when the rule only applies once central package management is in play. + bool RequiresCentralPackageManagement => false; + + bool IsEnabled(AnalysisOptions options) => true; + + IEnumerable Analyze(AnalysisContext context); +} diff --git a/CentralConfigGenerator.Core/Analysis/RuleCatalog.cs b/CentralConfigGenerator.Core/Analysis/RuleCatalog.cs new file mode 100644 index 0000000..7e5551c --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/RuleCatalog.cs @@ -0,0 +1,31 @@ +using CentralConfigGenerator.Core.Analysis.Rules; + +namespace CentralConfigGenerator.Core.Analysis; + +/// The complete set of analysis rules, and lookups used by the explain command. +public static class RuleCatalog +{ + public static IReadOnlyList CreateAll() => + [ + new SecurityVulnerabilityRule(), + new InlineVersionUnderCpmRule(), + new MissingCentralVersionRule(), + new DeprecatedPackageRule(), + new LicenseRiskRule(), + new VersionInconsistencyRule(), + new TransitiveConflictRule(), + new UnpinnedTransitiveDependencyRule(), + new FloatingVersionRule(), + new RedundantReferenceRule(), + new DuplicatePackageCasingRule(), + new OutdatedPackageRule(), + new PrereleaseInProductionRule(), + new OrphanedPackageVersionRule(), + new FrameworkAlignmentRule(), + new PropertyDriftRule(), + ]; + + public static IAnalysisRule? Find(string ruleId) => + CreateAll() + .FirstOrDefault(r => string.Equals(r.RuleId, ruleId, StringComparison.OrdinalIgnoreCase)); +} diff --git a/CentralConfigGenerator.Core/Analysis/RuleIds.cs b/CentralConfigGenerator.Core/Analysis/RuleIds.cs new file mode 100644 index 0000000..6c29579 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/RuleIds.cs @@ -0,0 +1,42 @@ +namespace CentralConfigGenerator.Core.Analysis; + +/// Stable identifiers for every analysis rule, usable with explain and --rules. +public static class RuleIds +{ + public const string SecurityVulnerability = "SecurityVulnerability"; + public const string InlineVersionUnderCpm = "InlineVersionUnderCpm"; + public const string LicenseRisk = "LicenseRisk"; + public const string VersionInconsistency = "VersionInconsistency"; + public const string FloatingVersion = "FloatingVersion"; + public const string TransitiveConflict = "TransitiveConflict"; + public const string DuplicatePackageCasing = "DuplicatePackageCasing"; + public const string RedundantReference = "RedundantReference"; + public const string OutdatedPackage = "OutdatedPackage"; + public const string DeprecatedPackage = "DeprecatedPackage"; + public const string FrameworkAlignment = "FrameworkAlignment"; + public const string PrereleaseInProduction = "PrereleaseInProduction"; + public const string MissingCentralVersion = "MissingCentralVersion"; + public const string OrphanedPackageVersion = "OrphanedPackageVersion"; + public const string UnpinnedTransitiveDependency = "UnpinnedTransitiveDependency"; + public const string PropertyDrift = "PropertyDrift"; + + public static IReadOnlyList All { get; } = + [ + SecurityVulnerability, + InlineVersionUnderCpm, + LicenseRisk, + VersionInconsistency, + FloatingVersion, + TransitiveConflict, + DuplicatePackageCasing, + RedundantReference, + OutdatedPackage, + DeprecatedPackage, + FrameworkAlignment, + PrereleaseInProduction, + MissingCentralVersion, + OrphanedPackageVersion, + UnpinnedTransitiveDependency, + PropertyDrift, + ]; +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/DeprecatedPackageRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/DeprecatedPackageRule.cs new file mode 100644 index 0000000..ee7e9c9 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/DeprecatedPackageRule.cs @@ -0,0 +1,48 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Reports packages the author has marked deprecated on the feed. +public sealed class DeprecatedPackageRule : IAnalysisRule +{ + public string RuleId => RuleIds.DeprecatedPackage; + + public string Title => "Deprecated package"; + + public string Description => "The package author marked this package as deprecated."; + + public string Rationale => + "Deprecated packages stop receiving fixes, including security fixes. The feed usually " + + "names a replacement, so the migration path is known."; + + public Severity DefaultSeverity => Severity.High; + + public bool RequiresNetwork => true; + + public bool IsEnabled(AnalysisOptions options) => options.Deprecated; + + public IEnumerable Analyze(AnalysisContext context) + { + foreach (var (packageId, snapshot) in context.Metadata) + { + if (!snapshot.IsDeprecated || context.IsIgnored(packageId)) + { + continue; + } + + var reasons = snapshot.DeprecationReasons.Count > 0 + ? string.Join(", ", snapshot.DeprecationReasons) + : "no reason given"; + + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + PackageId = packageId, + Version = context.EffectiveVersion(packageId), + Message = $"'{packageId}' is deprecated ({reasons}).", + Recommendation = snapshot.AlternatePackageId is { } alternate + ? $"Migrate to '{alternate}'." + : snapshot.DeprecationMessage ?? "Find a maintained replacement.", + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/DuplicatePackageCasingRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/DuplicatePackageCasingRule.cs new file mode 100644 index 0000000..df914c7 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/DuplicatePackageCasingRule.cs @@ -0,0 +1,52 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Flags package ids that differ only by letter case. +public sealed class DuplicatePackageCasingRule : IAnalysisRule +{ + public string RuleId => RuleIds.DuplicatePackageCasing; + + public string Title => "Package id casing mismatch"; + + public string Description => + "The same package is spelled with different capitalisation in different projects."; + + public string Rationale => + "NuGet ids are case-insensitive but MSBuild item matching is not always: mismatched " + + "casing produces duplicate PackageVersion entries and confusing diffs."; + + public Severity DefaultSeverity => Severity.Low; + + public IEnumerable Analyze(AnalysisContext context) + { + var groups = context + .RelevantReferences.Select(r => r.PackageId) + .Concat(context.CentralVersions.Keys.Where(k => !context.IsIgnored(k))) + .GroupBy(id => id, StringComparer.OrdinalIgnoreCase); + + foreach (var group in groups) + { + var spellings = group.Distinct(StringComparer.Ordinal).ToList(); + if (spellings.Count < 2) + { + continue; + } + + // Prefer the feed's canonical spelling when it is known. + var canonical = + context.MetadataFor(group.Key)?.PackageId + ?? spellings.OrderBy(s => s, StringComparer.Ordinal).First(); + + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + PackageId = canonical, + Message = + $"'{group.Key}' appears with {spellings.Count} spellings: {string.Join(", ", spellings)}.", + Recommendation = $"Normalise every reference to '{canonical}'.", + Fix = FixKind.NormalizePackageCasing, + FixValue = canonical, + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/FloatingVersionRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/FloatingVersionRule.cs new file mode 100644 index 0000000..71f5c2b --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/FloatingVersionRule.cs @@ -0,0 +1,66 @@ +using CentralConfigGenerator.Core.Services; + +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Flags wildcard and open-ended version specifications. +public sealed class FloatingVersionRule : IAnalysisRule +{ + public string RuleId => RuleIds.FloatingVersion; + + public string Title => "Floating version"; + + public string Description => + "A version uses a wildcard (4.*) or an unbounded range ([4.0.0,))."; + + public string Rationale => + "Floating versions make builds non-reproducible: the same commit resolves to different " + + "binaries depending on when it was restored, which turns a feed publish into a surprise " + + "production change."; + + public Severity DefaultSeverity => Severity.Moderate; + + public IEnumerable Analyze(AnalysisContext context) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var (packageId, version) in AllVersions(context)) + { + if (!VersionSelector.IsFloating(version) || !seen.Add($"{packageId}|{version}")) + { + continue; + } + + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + PackageId = packageId, + Version = version, + Message = $"'{packageId}' uses the floating version '{version}'.", + Recommendation = + "Pin an exact version so restores are reproducible across machines and time.", + }; + } + } + + private static IEnumerable<(string PackageId, string Version)> AllVersions( + AnalysisContext context + ) + { + foreach (var reference in context.RelevantReferences) + { + if (!string.IsNullOrWhiteSpace(reference.Version)) + { + yield return (reference.PackageId, reference.Version!); + } + } + + foreach (var (packageId, version) in context.CentralVersions) + { + if (!context.IsIgnored(packageId)) + { + yield return (packageId, version); + } + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/FrameworkAlignmentRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/FrameworkAlignmentRule.cs new file mode 100644 index 0000000..121ebee --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/FrameworkAlignmentRule.cs @@ -0,0 +1,68 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Reports target-framework drift across the workspace. +public sealed class FrameworkAlignmentRule : IAnalysisRule +{ + public string RuleId => RuleIds.FrameworkAlignment; + + public string Title => "Target framework drift"; + + public string Description => + "Projects in the same workspace target different framework versions."; + + public string Rationale => + "Mixed target frameworks split the dependency graph: each TFM resolves packages " + + "independently, so a single central version can still produce two different builds."; + + public Severity DefaultSeverity => Severity.Low; + + public IEnumerable Analyze(AnalysisContext context) + { + if (context.TargetFrameworks.Count == 0) + { + yield break; + } + + var distinct = context + .TargetFrameworks.SelectMany(pair => pair.Value) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (distinct.Count < 2) + { + yield break; + } + + // The dominant framework is the one the most projects can build against. + var dominant = distinct + .OrderByDescending(tfm => + context.TargetFrameworks.Count(pair => + pair.Value.Contains(tfm, StringComparer.OrdinalIgnoreCase) + ) + ) + .ThenByDescending(tfm => tfm, StringComparer.OrdinalIgnoreCase) + .First(); + + foreach (var (projectPath, frameworks) in context.TargetFrameworks) + { + // A multi-targeted project that includes the dominant framework is aligned already. + if (frameworks.Contains(dominant, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + ProjectPath = projectPath, + Subject = string.Join(";", frameworks), + Message = + $"{Path.GetFileName(projectPath)} targets {string.Join(", ", frameworks)} " + + $"but not {dominant}, which the rest of the workspace builds against.", + Recommendation = + $"Add {dominant} to its target frameworks, or hoist TargetFramework into Directory.Build.props deliberately.", + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/InlineVersionUnderCpmRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/InlineVersionUnderCpmRule.cs new file mode 100644 index 0000000..1a09151 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/InlineVersionUnderCpmRule.cs @@ -0,0 +1,55 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Flags inline versions that survive after central package management was enabled. +public sealed class InlineVersionUnderCpmRule : IAnalysisRule +{ + public string RuleId => RuleIds.InlineVersionUnderCpm; + + public string Title => "Inline version under central package management"; + + public string Description => + "A project still declares Version on a PackageReference even though " + + "ManagePackageVersionsCentrally is enabled."; + + public string Rationale => + "NuGet raises NU1008 for these references, and where it does not, the inline version " + + "silently wins over the central one — exactly the drift CPM is meant to prevent."; + + public Severity DefaultSeverity => Severity.High; + + public bool RequiresCentralPackageManagement => true; + + public IEnumerable Analyze(AnalysisContext context) + { + foreach (var reference in context.RelevantReferences) + { + if (reference.IsGlobal || string.IsNullOrWhiteSpace(reference.Version)) + { + continue; + } + + var central = context.CentralVersions.TryGetValue(reference.PackageId, out var value) + ? value + : null; + + var drift = + central is not null + && !string.Equals(central, reference.Version, StringComparison.OrdinalIgnoreCase); + + yield return new Finding + { + RuleId = RuleId, + Severity = drift ? Severity.High : Severity.Moderate, + PackageId = reference.PackageId, + Version = reference.Version, + ProjectPath = reference.ProjectPath, + Message = drift + ? $"'{reference.PackageId}' pins {reference.Version} inline while Directory.Packages.props says {central}." + : $"'{reference.PackageId}' still declares an inline version of {reference.Version}.", + Recommendation = "Remove the Version attribute and let the central entry apply.", + Fix = FixKind.RemoveInlineVersion, + FixValue = central ?? reference.Version, + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/LicenseRiskRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/LicenseRiskRule.cs new file mode 100644 index 0000000..bbac379 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/LicenseRiskRule.cs @@ -0,0 +1,106 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Flags copyleft, proprietary and unknown licences. +public sealed class LicenseRiskRule : IAnalysisRule +{ + /// Licence identifiers that impose reciprocal obligations on distributed software. + private static readonly string[] StrongCopyleft = + [ + "GPL-2.0", + "GPL-3.0", + "AGPL-3.0", + "SSPL", + "OSL-3.0", + "EUPL", + ]; + + private static readonly string[] WeakCopyleft = ["LGPL", "MPL-2.0", "MS-RL", "CDDL", "EPL"]; + + public string RuleId => RuleIds.LicenseRisk; + + public string Title => "Licence risk"; + + public string Description => + "A dependency ships under a copyleft, proprietary or undeclared licence."; + + public string Rationale => + "Copyleft terms can propagate to the software that links the package, and an undeclared " + + "licence means no permission has actually been granted. Both need a human decision."; + + public Severity DefaultSeverity => Severity.Moderate; + + public bool RequiresNetwork => true; + + public bool IsEnabled(AnalysisOptions options) => options.Licenses; + + public IEnumerable Analyze(AnalysisContext context) + { + foreach (var (packageId, snapshot) in context.Metadata) + { + if (context.IsIgnored(packageId) || !snapshot.Found) + { + continue; + } + + var license = snapshot.License; + + if (string.IsNullOrWhiteSpace(license)) + { + yield return new Finding + { + RuleId = RuleId, + Severity = Severity.Low, + PackageId = packageId, + Version = context.EffectiveVersion(packageId), + Message = $"'{packageId}' does not declare a licence.", + Recommendation = + "Confirm the terms with the author before shipping this dependency.", + }; + continue; + } + + if (StrongCopyleft.Any(l => license.Contains(l, StringComparison.OrdinalIgnoreCase))) + { + yield return new Finding + { + RuleId = RuleId, + Severity = Severity.High, + PackageId = packageId, + Version = context.EffectiveVersion(packageId), + Message = $"'{packageId}' is licensed under {license} (strong copyleft).", + Recommendation = + "Distributing linked software may require releasing your source. Get legal sign-off.", + }; + continue; + } + + if (WeakCopyleft.Any(l => license.Contains(l, StringComparison.OrdinalIgnoreCase))) + { + yield return new Finding + { + RuleId = RuleId, + Severity = Severity.Moderate, + PackageId = packageId, + Version = context.EffectiveVersion(packageId), + Message = $"'{packageId}' is licensed under {license} (weak copyleft).", + Recommendation = + "Modifications to the package itself must usually be published. Keep it unforked.", + }; + continue; + } + + if (license.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + yield return new Finding + { + RuleId = RuleId, + Severity = Severity.Info, + PackageId = packageId, + Version = context.EffectiveVersion(packageId), + Message = $"'{packageId}' points at a licence URL rather than an SPDX expression.", + Recommendation = $"Review {license} manually.", + }; + } + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/MissingCentralVersionRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/MissingCentralVersionRule.cs new file mode 100644 index 0000000..6b9e45c --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/MissingCentralVersionRule.cs @@ -0,0 +1,54 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Flags references that have neither an inline nor a central version. +public sealed class MissingCentralVersionRule : IAnalysisRule +{ + public string RuleId => RuleIds.MissingCentralVersion; + + public string Title => "Package has no version anywhere"; + + public string Description => + "A PackageReference has no inline version and no matching PackageVersion entry."; + + public string Rationale => + "The restore fails with NU1010. It usually means a package was added by hand after the " + + "migration, or a PackageVersion entry was deleted."; + + public Severity DefaultSeverity => Severity.Critical; + + public bool RequiresCentralPackageManagement => true; + + public IEnumerable Analyze(AnalysisContext context) + { + foreach (var reference in context.RelevantReferences) + { + if ( + reference.IsGlobal + || !string.IsNullOrWhiteSpace(reference.Version) + || context.CentralVersions.ContainsKey(reference.PackageId) + ) + { + continue; + } + + var suggested = context + .MetadataFor(reference.PackageId) + ?.LatestStable?.ToNormalizedString(); + + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + PackageId = reference.PackageId, + ProjectPath = reference.ProjectPath, + Message = + $"'{reference.PackageId}' has no version: no inline attribute and no PackageVersion entry.", + Recommendation = suggested is null + ? "Add a PackageVersion entry to Directory.Packages.props." + : $"Add .", + Fix = suggested is null ? FixKind.None : FixKind.SetCentralVersion, + FixValue = suggested, + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/OrphanedPackageVersionRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/OrphanedPackageVersionRule.cs new file mode 100644 index 0000000..c02bd3a --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/OrphanedPackageVersionRule.cs @@ -0,0 +1,48 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Flags PackageVersion entries that no project references any more. +public sealed class OrphanedPackageVersionRule : IAnalysisRule +{ + public string RuleId => RuleIds.OrphanedPackageVersion; + + public string Title => "Unused central version entry"; + + public string Description => + "Directory.Packages.props pins a package that no project references."; + + public string Rationale => + "Dead entries accumulate after refactors and make the central file look like it " + + "describes dependencies that no longer exist. They also confuse audit tooling."; + + public Severity DefaultSeverity => Severity.Info; + + public bool RequiresCentralPackageManagement => true; + + public IEnumerable Analyze(AnalysisContext context) + { + var referenced = context + .References.Select(r => r.PackageId) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var (packageId, version) in context.CentralVersions) + { + if (referenced.Contains(packageId) || context.IsIgnored(packageId)) + { + continue; + } + + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + PackageId = packageId, + Version = version, + ProjectPath = context.PackagesPropsPath, + Message = $"'{packageId}' is pinned centrally but referenced by no project.", + Recommendation = "Remove the PackageVersion entry.", + Fix = FixKind.RemoveOrphanedPackageVersion, + FixValue = packageId, + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/OutdatedPackageRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/OutdatedPackageRule.cs new file mode 100644 index 0000000..127d08b --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/OutdatedPackageRule.cs @@ -0,0 +1,71 @@ +using NuGet.Versioning; + +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Reports packages that lag behind the newest published version. +public sealed class OutdatedPackageRule : IAnalysisRule +{ + public string RuleId => RuleIds.OutdatedPackage; + + public string Title => "Outdated package"; + + public string Description => "A newer version of the package is published on the feed."; + + public string Rationale => + "Severity scales with the size of the gap: a patch behind is noise, a major behind is a " + + "migration that only gets harder to do later."; + + public Severity DefaultSeverity => Severity.Low; + + public bool RequiresNetwork => true; + + public bool IsEnabled(AnalysisOptions options) => options.Outdated; + + public IEnumerable Analyze(AnalysisContext context) + { + foreach (var (packageId, snapshot) in context.Metadata) + { + if (context.IsIgnored(packageId) || !snapshot.Found) + { + continue; + } + + var current = context.EffectiveVersion(packageId); + if (current is null || !NuGetVersion.TryParse(current, out var currentVersion)) + { + continue; + } + + var latest = context.Options.IncludePrerelease + ? snapshot.LatestIncludingPrerelease + : snapshot.LatestStable; + + if (latest is null || latest <= currentVersion) + { + continue; + } + + var majorGap = latest.Major - currentVersion.Major; + var severity = majorGap switch + { + >= 2 => Severity.Moderate, + 1 => Severity.Low, + _ => latest.Minor > currentVersion.Minor ? Severity.Low : Severity.Info, + }; + + yield return new Finding + { + RuleId = RuleId, + Severity = severity, + PackageId = packageId, + Version = current, + Message = + $"'{packageId}' is on {current}; {latest.ToNormalizedString()} is available" + + (majorGap > 0 ? $" ({majorGap} major version(s) behind)." : "."), + Recommendation = $"Update to {latest.ToNormalizedString()}.", + Fix = FixKind.SetCentralVersion, + FixValue = latest.ToNormalizedString(), + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/PrereleaseInProductionRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/PrereleaseInProductionRule.cs new file mode 100644 index 0000000..d2e476c --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/PrereleaseInProductionRule.cs @@ -0,0 +1,57 @@ +using CentralConfigGenerator.Core.Services; + +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Flags pre-release versions outside of test-only packages. +public sealed class PrereleaseInProductionRule : IAnalysisRule +{ + public string RuleId => RuleIds.PrereleaseInProduction; + + public string Title => "Pre-release dependency"; + + public string Description => "A package resolves to a pre-release version."; + + public string Rationale => + "Pre-release packages can be unlisted or replaced by the author without notice, and their " + + "API is not covered by any compatibility promise."; + + public Severity DefaultSeverity => Severity.Low; + + public bool IsEnabled(AnalysisOptions options) => !options.IncludePrerelease; + + public IEnumerable Analyze(AnalysisContext context) + { + var reported = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var reference in context.RelevantReferences) + { + var version = reference.Version ?? context.EffectiveVersion(reference.PackageId); + + if ( + version is null + || !VersionSelector.IsPrerelease(version) + || !reported.Add(reference.PackageId) + ) + { + continue; + } + + var stable = context.MetadataFor(reference.PackageId)?.LatestStable; + + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + PackageId = reference.PackageId, + Version = version, + ProjectPath = reference.ProjectPath, + Message = $"'{reference.PackageId}' uses pre-release version {version}.", + Recommendation = stable is null + ? "Move to a stable release before shipping." + : $"A stable {stable.ToNormalizedString()} is available.", + Fix = stable is null ? FixKind.None : FixKind.SetCentralVersion, + FixValue = stable?.ToNormalizedString(), + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/PropertyDriftRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/PropertyDriftRule.cs new file mode 100644 index 0000000..dc2d0f9 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/PropertyDriftRule.cs @@ -0,0 +1,48 @@ +using CentralConfigGenerator.Core.Migration; + +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Reports properties repeated identically in every project, which belong in Directory.Build.props. +public sealed class PropertyDriftRule : IAnalysisRule +{ + public string RuleId => RuleIds.PropertyDrift; + + public string Title => "Property duplicated across projects"; + + public string Description => + "Several projects declare the same MSBuild property with the same value."; + + public string Rationale => + "Copies drift. One project eventually gets edited and the difference is invisible in " + + "review. Directory.Build.props makes the value single-sourced."; + + public Severity DefaultSeverity => Severity.Info; + + public IEnumerable Analyze(AnalysisContext context) + { + if (context.Projects.Count < 2) + { + yield break; + } + + var hoistable = PropertyHoister.FindHoistableProperties( + context.Projects, + BuildPropertyDefaults.Recommended + ); + + foreach (var (name, value) in hoistable.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase)) + { + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + Subject = name, + Message = $"'{name}' is set to '{value}' in every project that declares it.", + // Hoisting also has to delete the property from every project, which is what the + // 'build' command does transactionally. Doing half of it here would be worse. + Recommendation = + $"Move {name} into Directory.Build.props (run 'central-config build' or 'migrate --unify-props').", + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/RedundantReferenceRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/RedundantReferenceRule.cs new file mode 100644 index 0000000..2a24b32 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/RedundantReferenceRule.cs @@ -0,0 +1,72 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Flags the same package referenced twice inside one project. +public sealed class RedundantReferenceRule : IAnalysisRule +{ + public string RuleId => RuleIds.RedundantReference; + + public string Title => "Duplicate package reference"; + + public string Description => "One project declares the same PackageReference more than once."; + + public string Rationale => + "Duplicates trigger NU1504 warnings and make it ambiguous which metadata (PrivateAssets, " + + "IncludeAssets) actually applies."; + + public Severity DefaultSeverity => Severity.Moderate; + + public IEnumerable Analyze(AnalysisContext context) + { + var groups = context.RelevantReferences.GroupBy( + r => (r.ProjectPath, r.PackageId), + TupleComparer.Instance + ); + + foreach (var group in groups) + { + var entries = group.ToList(); + if (entries.Count < 2) + { + continue; + } + + // References separated by a condition are legitimate (per-TFM references). + if (entries.All(e => !string.IsNullOrWhiteSpace(e.Condition)) + && entries.Select(e => e.Condition).Distinct(StringComparer.OrdinalIgnoreCase).Count() == entries.Count) + { + continue; + } + + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + PackageId = group.Key.PackageId, + ProjectPath = group.Key.ProjectPath, + Message = + $"'{group.Key.PackageId}' is referenced {entries.Count} times in {Path.GetFileName(group.Key.ProjectPath)}.", + Recommendation = "Keep a single PackageReference and delete the duplicates.", + Fix = FixKind.RemoveDuplicateReference, + FixValue = group.Key.PackageId, + }; + } + } + + private sealed class TupleComparer : IEqualityComparer<(string ProjectPath, string PackageId)> + { + public static readonly TupleComparer Instance = new(); + + public bool Equals( + (string ProjectPath, string PackageId) x, + (string ProjectPath, string PackageId) y + ) => + string.Equals(x.ProjectPath, y.ProjectPath, StringComparison.OrdinalIgnoreCase) + && string.Equals(x.PackageId, y.PackageId, StringComparison.OrdinalIgnoreCase); + + public int GetHashCode((string ProjectPath, string PackageId) obj) => + HashCode.Combine( + obj.ProjectPath.ToLowerInvariant(), + obj.PackageId.ToLowerInvariant() + ); + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/SecurityVulnerabilityRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/SecurityVulnerabilityRule.cs new file mode 100644 index 0000000..d503dfb --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/SecurityVulnerabilityRule.cs @@ -0,0 +1,90 @@ +using NuGet.Versioning; + +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Reports known security advisories affecting the resolved versions. +public sealed class SecurityVulnerabilityRule : IAnalysisRule +{ + public string RuleId => RuleIds.SecurityVulnerability; + + public string Title => "Known security vulnerability"; + + public string Description => + "The resolved version of a package is covered by a published security advisory."; + + public string Rationale => + "A vulnerable dependency is exploitable in production regardless of how the rest of the " + + "code is written. This is the one finding that should always block a release."; + + public Severity DefaultSeverity => Severity.Critical; + + public bool RequiresNetwork => true; + + public bool IsEnabled(AnalysisOptions options) => options.Audit; + + public IEnumerable Analyze(AnalysisContext context) + { + foreach (var (packageId, snapshot) in context.Metadata) + { + if (context.IsIgnored(packageId) || snapshot.Vulnerabilities.Count == 0) + { + continue; + } + + var current = context.EffectiveVersion(packageId); + if (current is null || !NuGetVersion.TryParse(current, out var currentVersion)) + { + continue; + } + + var affecting = snapshot + .Vulnerabilities.Where(v => + v.AffectedRange is not null + && NuGetVersion.TryParse(v.AffectedRange, out var affected) + && affected.Equals(currentVersion) + ) + .ToList(); + + if (affecting.Count == 0) + { + continue; + } + + var worst = affecting.Max(v => v.Severity); + var fixedVersion = FindFirstSafeVersion(snapshot, currentVersion); + + yield return new Finding + { + RuleId = RuleId, + Severity = worst, + PackageId = packageId, + Version = current, + Message = + $"'{packageId}' {current} has {affecting.Count} known advisory/advisories ({worst}).", + Recommendation = fixedVersion is null + ? $"Review {affecting[0].AdvisoryUrl}" + : $"Upgrade to {fixedVersion.ToNormalizedString()}. See {affecting[0].AdvisoryUrl}", + Fix = fixedVersion is null ? FixKind.None : FixKind.SetCentralVersion, + FixValue = fixedVersion?.ToNormalizedString(), + }; + } + } + + /// Finds the lowest version above the current one that carries no advisory. + private static NuGetVersion? FindFirstSafeVersion( + NuGet.PackageMetadataSnapshot snapshot, + NuGetVersion current + ) + { + var vulnerable = snapshot + .Vulnerabilities.Select(v => v.AffectedRange) + .Where(v => v is not null) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + return snapshot + .AllVersions.Where(v => v > current && !v.IsPrerelease) + .Where(v => !vulnerable.Contains(v.ToNormalizedString())) + .OrderBy(v => v) + .FirstOrDefault(); + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/TransitiveConflictRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/TransitiveConflictRule.cs new file mode 100644 index 0000000..64f18b4 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/TransitiveConflictRule.cs @@ -0,0 +1,62 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Reports packages whose transitive graph resolves to more than one version. +public sealed class TransitiveConflictRule : IAnalysisRule +{ + public string RuleId => RuleIds.TransitiveConflict; + + public string Title => "Divergent transitive resolution"; + + public string Description => + "The same package resolves to different versions in different projects or frameworks."; + + public string Rationale => + "Divergence is invisible in the project files but very visible at runtime, where one " + + "assembly loads and the other does not. Pinning centrally collapses the graph."; + + public Severity DefaultSeverity => Severity.Moderate; + + public bool IsEnabled(AnalysisOptions options) => options.Transitive; + + public IEnumerable Analyze(AnalysisContext context) + { + if (!context.Graph.IsAvailable) + { + yield break; + } + + foreach (var group in context.Graph.DivergentPackages) + { + if (context.IsIgnored(group.Key)) + { + continue; + } + + var versions = group + .Select(p => p.ResolvedVersion) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(v => v, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var highest = versions[^1]; + var isDirect = group.Any(p => !p.IsTransitive); + + yield return new Finding + { + RuleId = RuleId, + Severity = isDirect ? Severity.Moderate : Severity.Low, + PackageId = group.Key, + Version = highest, + Message = + $"'{group.Key}' resolves to {versions.Count} versions across the graph: {string.Join(", ", versions)}.", + Recommendation = + $"Pin '{group.Key}' to {highest} centrally" + + (context.TransitivePinningEnabled + ? "." + : " and enable CentralPackageTransitivePinningEnabled."), + Fix = FixKind.SetCentralVersion, + FixValue = highest, + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/UnpinnedTransitiveDependencyRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/UnpinnedTransitiveDependencyRule.cs new file mode 100644 index 0000000..26a22fe --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/UnpinnedTransitiveDependencyRule.cs @@ -0,0 +1,101 @@ +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// +/// Reports vulnerable or deprecated transitive dependencies that no central entry pins, +/// so they cannot be fixed without upgrading their parent. +/// +public sealed class UnpinnedTransitiveDependencyRule : IAnalysisRule +{ + public string RuleId => RuleIds.UnpinnedTransitiveDependency; + + public string Title => "Risky transitive dependency is not pinned"; + + public string Description => + "A transitive dependency carries an advisory or deprecation notice but has no " + + "PackageVersion entry that could override it."; + + public string Rationale => + "Transitive pinning is the only way to patch a vulnerable indirect dependency without " + + "waiting for the intermediate package to publish a fix."; + + public Severity DefaultSeverity => Severity.High; + + public bool RequiresNetwork => true; + + public bool IsEnabled(AnalysisOptions options) => options.Transitive && options.Audit; + + public IEnumerable Analyze(AnalysisContext context) + { + if (!context.Graph.IsAvailable) + { + yield break; + } + + var directIds = context + .References.Select(r => r.PackageId) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var package in context.Graph.Transitive) + { + if ( + directIds.Contains(package.PackageId) + || context.CentralVersions.ContainsKey(package.PackageId) + || context.IsIgnored(package.PackageId) + || !seen.Add(package.PackageId) + ) + { + continue; + } + + var snapshot = context.MetadataFor(package.PackageId); + if (snapshot is null) + { + continue; + } + + var affected = snapshot.Vulnerabilities.Any(v => + string.Equals(v.AffectedRange, package.ResolvedVersion, StringComparison.OrdinalIgnoreCase) + ); + + if (!affected && !snapshot.IsDeprecated) + { + continue; + } + + var safe = snapshot + .AllVersions.Where(v => + !v.IsPrerelease + && v.ToNormalizedString() != package.ResolvedVersion + && !snapshot.Vulnerabilities.Any(x => + string.Equals( + x.AffectedRange, + v.ToNormalizedString(), + StringComparison.OrdinalIgnoreCase + ) + ) + ) + .OrderByDescending(v => v) + .FirstOrDefault(); + + yield return new Finding + { + RuleId = RuleId, + Severity = affected ? Severity.High : Severity.Moderate, + PackageId = package.PackageId, + Version = package.ResolvedVersion, + ProjectPath = package.ProjectPath, + Message = + $"Transitive '{package.PackageId}' {package.ResolvedVersion} is " + + (affected ? "vulnerable" : "deprecated") + + " and is not pinned centrally.", + Recommendation = safe is null + ? "Enable CentralPackageTransitivePinningEnabled and pin a safe version." + : $"Pin '{package.PackageId}' to {safe.ToNormalizedString()} with transitive pinning enabled.", + Fix = safe is null ? FixKind.None : FixKind.SetCentralVersion, + FixValue = safe?.ToNormalizedString(), + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Rules/VersionInconsistencyRule.cs b/CentralConfigGenerator.Core/Analysis/Rules/VersionInconsistencyRule.cs new file mode 100644 index 0000000..2c08ab4 --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Rules/VersionInconsistencyRule.cs @@ -0,0 +1,60 @@ +using CentralConfigGenerator.Core.Services; + +namespace CentralConfigGenerator.Core.Analysis.Rules; + +/// Flags packages referenced with more than one version across the workspace. +public sealed class VersionInconsistencyRule : IAnalysisRule +{ + public string RuleId => RuleIds.VersionInconsistency; + + public string Title => "Inconsistent package versions"; + + public string Description => + "The same package is referenced with different versions by different projects."; + + public string Rationale => + "Divergent versions produce different binaries per project and make runtime binding " + + "failures likely. Central package management removes the drift by pinning one version."; + + public Severity DefaultSeverity => Severity.Moderate; + + public IEnumerable Analyze(AnalysisContext context) + { + var groups = context + .RelevantReferences.Where(r => !string.IsNullOrWhiteSpace(r.Version)) + .GroupBy(r => r.PackageId, StringComparer.OrdinalIgnoreCase); + + foreach (var group in groups) + { + var versions = group + .Select(r => r.Version!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(v => v, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (versions.Count < 2) + { + continue; + } + + var highest = versions + .OrderBy(v => v, Comparer.Create((a, b) => + VersionSelector.Compare(a, b, global::NuGet.Versioning.VersionComparison.VersionRelease))) + .Last(); + + yield return new Finding + { + RuleId = RuleId, + Severity = DefaultSeverity, + PackageId = group.Key, + Message = + $"'{group.Key}' is referenced with {versions.Count} versions: {string.Join(", ", versions)}.", + Recommendation = + $"Pin a single version (highest observed is {highest}) in Directory.Packages.props.", + Fix = FixKind.SetCentralVersion, + FixValue = highest, + Version = highest, + }; + } + } +} diff --git a/CentralConfigGenerator.Core/Analysis/Severity.cs b/CentralConfigGenerator.Core/Analysis/Severity.cs new file mode 100644 index 0000000..4a19cfa --- /dev/null +++ b/CentralConfigGenerator.Core/Analysis/Severity.cs @@ -0,0 +1,13 @@ +namespace CentralConfigGenerator.Core.Analysis; + +/// Finding severity, ordered so thresholds can be compared numerically. +public enum Severity +{ + /// Used only as a --fail-on threshold meaning "never fail". + Never = 0, + Info = 1, + Low = 2, + Moderate = 3, + High = 4, + Critical = 5, +} diff --git a/CentralConfigGenerator.Core/CentralConfigGenerator.Core.csproj b/CentralConfigGenerator.Core/CentralConfigGenerator.Core.csproj index 9d48c53..8eacf32 100644 --- a/CentralConfigGenerator.Core/CentralConfigGenerator.Core.csproj +++ b/CentralConfigGenerator.Core/CentralConfigGenerator.Core.csproj @@ -1,5 +1,12 @@ - - + + + net8.0;net9.0;net10.0 + + + + + + diff --git a/CentralConfigGenerator.Core/Configuration/CentralConfigSettings.cs b/CentralConfigGenerator.Core/Configuration/CentralConfigSettings.cs new file mode 100644 index 0000000..9d82c4e --- /dev/null +++ b/CentralConfigGenerator.Core/Configuration/CentralConfigSettings.cs @@ -0,0 +1,67 @@ +using System.Text.Json.Serialization; +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Configuration; + +/// +/// Contents of a .centralconfig.json file. Every value is optional; anything left unset +/// falls back to the command-line default. +/// +public sealed record CentralConfigSettings +{ + [JsonPropertyName("$schema")] + public string? Schema { get; init; } + + public VersionResolutionStrategyName? ConflictStrategy { get; init; } + + public bool? Backup { get; init; } + + public string? BackupDir { get; init; } + + public bool? AddGitignore { get; init; } + + public Severity? FailOn { get; init; } + + public string? Baseline { get; init; } + + public RetentionSettings? Retention { get; init; } + + public Dictionary? Rules { get; init; } + + public string? ExcludeDirs { get; init; } + + public bool? TransitivePinning { get; init; } + + public bool? IgnorePrerelease { get; init; } + + public LineEndingStyle? LineEnding { get; init; } + + public string? Encoding { get; init; } + + /// Property names promoted into Directory.Build.props by the build command. + public List? BuildProperties { get; init; } + + /// Packages that analysis should ignore entirely. + public List? IgnorePackages { get; init; } + + public string? SourceDirectory { get; init; } +} + +public sealed record RetentionSettings +{ + public bool? Enabled { get; init; } + + public int? MaxBackups { get; init; } +} + +/// Serialisable mirror of VersionResolutionStrategy, including Fail. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum VersionResolutionStrategyName +{ + Highest, + Lowest, + MostCommon, + Fail, + Manual, +} diff --git a/CentralConfigGenerator.Core/Configuration/ConfigurationLoader.cs b/CentralConfigGenerator.Core/Configuration/ConfigurationLoader.cs new file mode 100644 index 0000000..22a8abd --- /dev/null +++ b/CentralConfigGenerator.Core/Configuration/ConfigurationLoader.cs @@ -0,0 +1,119 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using CentralConfigGenerator.Core.IO; + +namespace CentralConfigGenerator.Core.Configuration; + +/// +/// Discovers and reads .centralconfig.json by walking up from a starting directory, +/// mirroring how .editorconfig and global.json are resolved. +/// +public sealed class ConfigurationLoader(IFileSystem fileSystem) +{ + public const string FileName = ".centralconfig.json"; + + public const string SchemaUrl = + "https://raw.githubusercontent.com/TarasKovalenko/CentralConfigGenerator/main/schemas/centralconfig.schema.json"; + + public static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() }, + }; + + public string? Find(string startDirectory) + { + var directory = new DirectoryInfo(Path.GetFullPath(startDirectory)); + + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, FileName); + if (fileSystem.FileExists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + return null; + } + + public async Task<(CentralConfigSettings? Settings, string? Path, string? Error)> LoadAsync( + string startDirectory, + CancellationToken cancellationToken = default + ) + { + var path = Find(startDirectory); + if (path is null) + { + return (null, null, null); + } + + try + { + var json = await fileSystem.ReadAllTextAsync(path, cancellationToken); + var settings = JsonSerializer.Deserialize( + json, + SerializerOptions + ); + return (settings, path, null); + } + catch (JsonException ex) + { + return (null, path, ex.Message); + } + } + + public async Task ScaffoldAsync( + string directory, + bool overwrite, + CancellationToken cancellationToken = default + ) + { + var path = Path.Combine(Path.GetFullPath(directory), FileName); + + if (fileSystem.FileExists(path) && !overwrite) + { + return path; + } + + var template = new CentralConfigSettings + { + Schema = SchemaUrl, + ConflictStrategy = VersionResolutionStrategyName.Highest, + Backup = true, + AddGitignore = true, + FailOn = Analysis.Severity.High, + TransitivePinning = false, + IgnorePrerelease = false, + Retention = new RetentionSettings { Enabled = true, MaxBackups = 5 }, + Rules = new Dictionary + { + [Analysis.RuleIds.OutdatedPackage] = "Low", + [Analysis.RuleIds.LicenseRisk] = "Moderate", + }, + BuildProperties = + [ + "TargetFramework", + "TargetFrameworks", + "ImplicitUsings", + "Nullable", + "LangVersion", + ], + }; + + await fileSystem.WriteAllTextAsync( + path, + JsonSerializer.Serialize(template, SerializerOptions), + cancellationToken + ); + + return path; + } +} diff --git a/CentralConfigGenerator.Core/Discovery/DiscoveryOptions.cs b/CentralConfigGenerator.Core/Discovery/DiscoveryOptions.cs new file mode 100644 index 0000000..d320132 --- /dev/null +++ b/CentralConfigGenerator.Core/Discovery/DiscoveryOptions.cs @@ -0,0 +1,52 @@ +using System.Text.RegularExpressions; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Discovery; + +/// +/// Controls which MSBuild files a scan picks up. +/// +public sealed record DiscoveryOptions +{ + public static readonly string[] DefaultExcludedDirectories = + [ + "bin", + "obj", + ".git", + ".vs", + ".idea", + "node_modules", + "packages", + "artifacts", + "TestResults", + // Our own rollback copies are full project trees; scanning them would double-count + // every project and let a fix rewrite the backup it is supposed to restore from. + ".centralconfig-backups", + ]; + + /// Root directory to scan. Ignored when is set. + public string RootDirectory { get; init; } = Directory.GetCurrentDirectory(); + + /// Optional solution (.sln/.slnx) or solution filter (.slnf) that scopes the scan. + public string? SolutionPath { get; init; } + + /// Optional single project path that scopes the scan. + public string? ProjectPath { get; init; } + + /// Regex applied to directory names and relative paths; matches are skipped. + public string? ExcludePattern { get; init; } + + /// Project kinds to include. Defaults to C#, F# and VB. + public IReadOnlyCollection IncludedKinds { get; init; } = + [ProjectKind.CSharp, ProjectKind.FSharp, ProjectKind.VisualBasic]; + + public bool Recursive { get; init; } = true; + + /// When false, the built-in bin/obj/node_modules exclusions are not applied. + public bool ApplyDefaultExclusions { get; init; } = true; + + public Regex? CompileExcludeRegex() => + string.IsNullOrWhiteSpace(ExcludePattern) + ? null + : new Regex(ExcludePattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); +} diff --git a/CentralConfigGenerator.Core/Discovery/IProjectDiscoveryService.cs b/CentralConfigGenerator.Core/Discovery/IProjectDiscoveryService.cs new file mode 100644 index 0000000..f01ed0a --- /dev/null +++ b/CentralConfigGenerator.Core/Discovery/IProjectDiscoveryService.cs @@ -0,0 +1,15 @@ +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Discovery; + +public interface IProjectDiscoveryService +{ + Task> DiscoverAsync( + DiscoveryOptions options, + CancellationToken cancellationToken = default + ); + + Task LoadAsync(string path, CancellationToken cancellationToken = default); + + Task SaveAsync(ProjectFile projectFile, CancellationToken cancellationToken = default); +} diff --git a/CentralConfigGenerator.Core/Discovery/ProjectDiscoveryService.cs b/CentralConfigGenerator.Core/Discovery/ProjectDiscoveryService.cs new file mode 100644 index 0000000..b47aa6f --- /dev/null +++ b/CentralConfigGenerator.Core/Discovery/ProjectDiscoveryService.cs @@ -0,0 +1,183 @@ +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Discovery; + +/// +/// Finds project files by scanning a directory tree or by walking a solution, preserving each +/// file's original encoding and line endings so they can be rewritten faithfully. +/// +public sealed class ProjectDiscoveryService(IFileSystem fileSystem) : IProjectDiscoveryService +{ + public async Task> DiscoverAsync( + DiscoveryOptions options, + CancellationToken cancellationToken = default + ) + { + var paths = ResolvePaths(options); + var results = new List(paths.Count); + + foreach (var path in paths) + { + cancellationToken.ThrowIfCancellationRequested(); + + var file = await LoadAsync(path, cancellationToken); + if (file is not null) + { + results.Add(file); + } + } + + return results + .OrderBy(f => f.Path, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public async Task LoadAsync( + string path, + CancellationToken cancellationToken = default + ) + { + if (!fileSystem.FileExists(path)) + { + return null; + } + + var bytes = await fileSystem.ReadAllBytesAsync(path, cancellationToken); + var (content, format) = EncodingDetector.Decode(bytes); + + return new ProjectFile + { + Path = path, + Content = content, + Kind = ProjectKindExtensions.FromPath(path), + Format = format, + }; + } + + public async Task SaveAsync( + ProjectFile projectFile, + CancellationToken cancellationToken = default + ) + { + var bytes = EncodingDetector.Encode(projectFile.Content, projectFile.Format); + await fileSystem.WriteAllBytesAsync(projectFile.Path, bytes, cancellationToken); + } + + private IReadOnlyList ResolvePaths(DiscoveryOptions options) + { + var kinds = options.IncludedKinds.ToHashSet(); + + if (!string.IsNullOrWhiteSpace(options.ProjectPath)) + { + var project = Path.GetFullPath(options.ProjectPath); + if (fileSystem.FileExists(project)) + { + return [project]; + } + + if (fileSystem.DirectoryExists(project)) + { + return ScanDirectory(project, options, kinds); + } + + return []; + } + + if (!string.IsNullOrWhiteSpace(options.SolutionPath)) + { + var solution = Path.GetFullPath(options.SolutionPath); + if (fileSystem.FileExists(solution) && SolutionReader.IsSolution(solution)) + { + var content = fileSystem.ReadAllTextAsync(solution).GetAwaiter().GetResult(); + return SolutionReader + .ReadProjects(solution, content) + .Where(fileSystem.FileExists) + .Where(p => kinds.Contains(ProjectKindExtensions.FromPath(p))) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + if (fileSystem.DirectoryExists(solution)) + { + return ScanDirectory(solution, options, kinds); + } + + return []; + } + + return ScanDirectory(Path.GetFullPath(options.RootDirectory), options, kinds); + } + + private IReadOnlyList ScanDirectory( + string root, + DiscoveryOptions options, + HashSet kinds + ) + { + if (!fileSystem.DirectoryExists(root)) + { + return []; + } + + var excludeRegex = options.CompileExcludeRegex(); + var results = new List(); + + foreach (var path in fileSystem.EnumerateFiles(root, "*", options.Recursive)) + { + var kind = ProjectKindExtensions.FromPath(path); + if (!kinds.Contains(kind)) + { + continue; + } + + if (IsExcluded(root, path, options, excludeRegex)) + { + continue; + } + + results.Add(path); + } + + return results; + } + + internal static bool IsExcluded( + string root, + string path, + DiscoveryOptions options, + System.Text.RegularExpressions.Regex? excludeRegex + ) + { + var relative = Path.GetRelativePath(root, path); + var segments = relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries + ); + + if (options.ApplyDefaultExclusions) + { + // The final segment is the file name itself and must not be matched against directory names. + for (var i = 0; i < segments.Length - 1; i++) + { + if ( + DiscoveryOptions.DefaultExcludedDirectories.Contains( + segments[i], + StringComparer.OrdinalIgnoreCase + ) + ) + { + return true; + } + } + } + + if (excludeRegex is null) + { + return false; + } + + return excludeRegex.IsMatch(relative.Replace(Path.DirectorySeparatorChar, '/')) + || segments.Any(excludeRegex.IsMatch); + } +} diff --git a/CentralConfigGenerator.Core/Discovery/SolutionReader.cs b/CentralConfigGenerator.Core/Discovery/SolutionReader.cs new file mode 100644 index 0000000..2f2dfb3 --- /dev/null +++ b/CentralConfigGenerator.Core/Discovery/SolutionReader.cs @@ -0,0 +1,130 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace CentralConfigGenerator.Core.Discovery; + +/// +/// Reads project paths out of .sln, .slnx and .slnf files. +/// +public static partial class SolutionReader +{ + public static bool IsSolution(string path) => + Path.GetExtension(path).ToLowerInvariant() is ".sln" or ".slnx" or ".slnf"; + + public static IReadOnlyList ReadProjects(string solutionPath, string content) + { + return Path.GetExtension(solutionPath).ToLowerInvariant() switch + { + ".slnx" => ReadSlnx(solutionPath, content), + ".slnf" => ReadSlnf(solutionPath, content), + _ => ReadSln(solutionPath, content), + }; + } + + private static IReadOnlyList ReadSln(string solutionPath, string content) + { + var root = Path.GetDirectoryName(Path.GetFullPath(solutionPath)) ?? "."; + var results = new List(); + + foreach (Match match in ProjectLineRegex().Matches(content)) + { + var relative = match.Groups["path"].Value.Trim(); + if (relative.Length == 0 || !HasProjectExtension(relative)) + { + continue; + } + + results.Add(Normalize(root, relative)); + } + + return results; + } + + private static IReadOnlyList ReadSlnx(string solutionPath, string content) + { + var root = Path.GetDirectoryName(Path.GetFullPath(solutionPath)) ?? "."; + var results = new List(); + + XDocument document; + try + { + document = XDocument.Parse(content); + } + catch (System.Xml.XmlException) + { + return results; + } + + foreach (var element in document.Descendants("Project")) + { + var relative = element.Attribute("Path")?.Value; + if (string.IsNullOrWhiteSpace(relative) || !HasProjectExtension(relative)) + { + continue; + } + + results.Add(Normalize(root, relative)); + } + + return results; + } + + private static IReadOnlyList ReadSlnf(string solutionPath, string content) + { + var root = Path.GetDirectoryName(Path.GetFullPath(solutionPath)) ?? "."; + var results = new List(); + + try + { + using var document = JsonDocument.Parse(content); + if (!document.RootElement.TryGetProperty("solution", out var solution)) + { + return results; + } + + if (solution.TryGetProperty("path", out var slnPath) && slnPath.GetString() is { } sln) + { + root = Path.GetDirectoryName(Path.GetFullPath(Normalize(root, sln))) ?? root; + } + + if (!solution.TryGetProperty("projects", out var projects)) + { + return results; + } + + foreach (var project in projects.EnumerateArray()) + { + if (project.GetString() is { } relative && HasProjectExtension(relative)) + { + results.Add(Normalize(root, relative)); + } + } + } + catch (JsonException) + { + // Malformed filter files simply yield no projects. + } + + return results; + } + + private static bool HasProjectExtension(string path) => + Path.GetExtension(path).ToLowerInvariant() is ".csproj" or ".fsproj" or ".vbproj"; + + private static string Normalize(string root, string relative) + { + var normalized = relative.Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + + return Path.GetFullPath( + Path.IsPathRooted(normalized) ? normalized : Path.Combine(root, normalized) + ); + } + + [GeneratedRegex( + "^Project\\(\"\\{[^}]+\\}\"\\)\\s*=\\s*\"[^\"]*\"\\s*,\\s*\"(?[^\"]+)\"", + RegexOptions.Multiline | RegexOptions.CultureInvariant + )] + private static partial Regex ProjectLineRegex(); +} diff --git a/CentralConfigGenerator.Core/Generators/BuildPropsWriter.cs b/CentralConfigGenerator.Core/Generators/BuildPropsWriter.cs new file mode 100644 index 0000000..e2e99e2 --- /dev/null +++ b/CentralConfigGenerator.Core/Generators/BuildPropsWriter.cs @@ -0,0 +1,116 @@ +using System.Text; +using System.Xml.Linq; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Generators; + +/// Renders and merges Directory.Build.props from a set of hoisted properties. +public static class BuildPropsWriter +{ + public const string HeaderComment = + " Generated by CentralConfigGenerator. Properties shared by every project in this tree. "; + + public static string Create( + IReadOnlyDictionary properties, + IReadOnlyList? order = null, + bool includeHeaderComment = true, + string indent = " " + ) + { + var builder = new StringBuilder(); + builder.Append("\n"); + + if (includeHeaderComment) + { + builder.Append(indent).Append("\n"); + } + + builder.Append(indent).Append("\n"); + + foreach (var (name, value) in Order(properties, order)) + { + builder + .Append(indent) + .Append(indent) + .Append('<') + .Append(name) + .Append('>') + .Append(Escape(value)) + .Append("\n"); + } + + builder.Append(indent).Append("\n"); + builder.Append("\n"); + return builder.ToString(); + } + + public static string Merge( + string existingContent, + IReadOnlyDictionary properties, + IReadOnlyList? order = null, + string indent = " " + ) + { + if ( + string.IsNullOrWhiteSpace(existingContent) + || !MsBuildXml.TryParse(existingContent, out var document, out _) + || document.Root is null + ) + { + return Create(properties, order); + } + + var root = document.Root; + var ns = root.Name.Namespace; + + var propertyGroup = MsBuildXml.Elements(root, "PropertyGroup").FirstOrDefault(); + if (propertyGroup is null) + { + propertyGroup = new XElement(ns + "PropertyGroup"); + root.AddFirst(new XText("\n" + indent), propertyGroup); + } + + foreach (var (name, value) in Order(properties, order)) + { + var existing = MsBuildXml.Descendants(root, name).FirstOrDefault(); + if (existing is not null) + { + existing.Value = value; + continue; + } + + propertyGroup.Add(new XText("\n" + indent + indent), new XElement(ns + name, value)); + } + + if (propertyGroup.LastNode is XElement) + { + propertyGroup.Add(new XText("\n" + indent)); + } + + return MsBuildXml.ToString(document); + } + + private static IEnumerable> Order( + IReadOnlyDictionary properties, + IReadOnlyList? order + ) + { + if (order is null || order.Count == 0) + { + return properties.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase); + } + + var rank = order + .Select((name, index) => (name, index)) + .ToDictionary(x => x.name, x => x.index, StringComparer.OrdinalIgnoreCase); + + return properties + .OrderBy(p => rank.TryGetValue(p.Key, out var index) ? index : int.MaxValue) + .ThenBy(p => p.Key, StringComparer.OrdinalIgnoreCase); + } + + private static string Escape(string value) => + value.Replace("&", "&").Replace("<", "<").Replace(">", ">"); +} diff --git a/CentralConfigGenerator.Core/Generators/PackagesPropsEditor.cs b/CentralConfigGenerator.Core/Generators/PackagesPropsEditor.cs new file mode 100644 index 0000000..07a3b3d --- /dev/null +++ b/CentralConfigGenerator.Core/Generators/PackagesPropsEditor.cs @@ -0,0 +1,171 @@ +using System.Xml.Linq; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Generators; + +/// Targeted edits to an existing Directory.Packages.props file. +public static class PackagesPropsEditor +{ + /// Sets (or adds) the version for one package. Returns null when nothing changed. + public static string? SetVersion(string content, string packageId, string version, string indent = " ") + { + if (!MsBuildXml.TryParse(content, out var document, out _) || document.Root is null) + { + return null; + } + + var element = FindEntry(document, packageId); + + if (element is not null) + { + var attribute = MsBuildXml.Attribute(element, "Version"); + if (attribute is not null) + { + if (string.Equals(attribute.Value, version, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + attribute.Value = version; + return MsBuildXml.ToString(document); + } + + var child = MsBuildXml.Elements(element, "Version").FirstOrDefault(); + if (child is not null) + { + if (string.Equals(child.Value.Trim(), version, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + child.Value = version; + } + else + { + element.SetAttributeValue("Version", version); + } + + return MsBuildXml.ToString(document); + } + + var root = document.Root; + var ns = root.Name.Namespace; + + var itemGroup = + MsBuildXml + .Elements(root, "ItemGroup") + .LastOrDefault(g => MsBuildXml.Elements(g, "PackageVersion").Any()); + + if (itemGroup is null) + { + itemGroup = new XElement(ns + "ItemGroup"); + root.Add(new XText("\n" + indent), itemGroup, new XText("\n")); + } + + itemGroup.Add( + new XText("\n" + indent + indent), + new XElement( + ns + "PackageVersion", + new XAttribute("Include", packageId), + new XAttribute("Version", version) + ) + ); + + if (itemGroup.LastNode is XElement) + { + itemGroup.Add(new XText("\n" + indent)); + } + + return MsBuildXml.ToString(document); + } + + /// Removes a package's central version entry. + public static string? RemoveEntry(string content, string packageId) + { + if (!MsBuildXml.TryParse(content, out var document, out _)) + { + return null; + } + + var element = FindEntry(document, packageId); + if (element is null) + { + return null; + } + + var parent = element.Parent; + MsBuildXml.RemoveWithWhitespace(element); + MsBuildXml.RemoveIfEmpty(parent); + + return MsBuildXml.ToString(document); + } + + /// Rewrites a package id so its casing matches the canonical spelling. + public static string? NormalizeCasing(string content, string canonicalId) + { + if (!MsBuildXml.TryParse(content, out var document, out _)) + { + return null; + } + + var changed = false; + + foreach (var element in MsBuildXml.Descendants(document, "PackageVersion")) + { + var attribute = MsBuildXml.Attribute(element, "Include"); + if ( + attribute is null + || !string.Equals(attribute.Value, canonicalId, StringComparison.OrdinalIgnoreCase) + || string.Equals(attribute.Value, canonicalId, StringComparison.Ordinal) + ) + { + continue; + } + + attribute.Value = canonicalId; + changed = true; + } + + return changed ? MsBuildXml.ToString(document) : null; + } + + /// Removes duplicate PackageVersion entries that differ only by casing. + public static string? DeduplicateEntries(string content) + { + if (!MsBuildXml.TryParse(content, out var document, out _)) + { + return null; + } + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var changed = false; + + foreach (var element in MsBuildXml.Descendants(document, "PackageVersion").ToList()) + { + var id = MsBuildXml.AttributeValue(element, "Include"); + if (string.IsNullOrWhiteSpace(id) || seen.Add(id)) + { + continue; + } + + var parent = element.Parent; + MsBuildXml.RemoveWithWhitespace(element); + MsBuildXml.RemoveIfEmpty(parent); + changed = true; + } + + return changed ? MsBuildXml.ToString(document) : null; + } + + private static XElement? FindEntry(XDocument document, string packageId) => + MsBuildXml + .Descendants(document, "PackageVersion") + .FirstOrDefault(e => + string.Equals( + MsBuildXml.AttributeValue(e, "Include") + ?? MsBuildXml.AttributeValue(e, "Update"), + packageId, + StringComparison.OrdinalIgnoreCase + ) + ); +} diff --git a/CentralConfigGenerator.Core/Generators/PackagesPropsWriter.cs b/CentralConfigGenerator.Core/Generators/PackagesPropsWriter.cs new file mode 100644 index 0000000..4cc1c18 --- /dev/null +++ b/CentralConfigGenerator.Core/Generators/PackagesPropsWriter.cs @@ -0,0 +1,258 @@ +using System.Text; +using System.Xml.Linq; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Generators; + +/// Options for rendering a Directory.Packages.props file. +public sealed record PackagesPropsOptions +{ + public bool TransitivePinning { get; init; } + + /// Packages emitted as GlobalPackageReference instead of PackageVersion. + public IReadOnlyCollection GlobalPackages { get; init; } = []; + + public bool IncludeHeaderComment { get; init; } = true; + + public string Indent { get; init; } = " "; +} + +/// +/// Renders and merges Directory.Packages.props. Merging preserves everything already in the file +/// — comments, conditions, item-group layout — and only touches the entries it must. +/// +public static class PackagesPropsWriter +{ + public const string HeaderComment = + " Generated by CentralConfigGenerator. Package versions are managed centrally. "; + + public static string Create( + IReadOnlyDictionary versions, + PackagesPropsOptions? options = null + ) + { + options ??= new PackagesPropsOptions(); + var globals = new HashSet(options.GlobalPackages, StringComparer.OrdinalIgnoreCase); + + var builder = new StringBuilder(); + builder.Append("\n"); + + if (options.IncludeHeaderComment) + { + builder.Append(options.Indent).Append("\n"); + } + + builder.Append(options.Indent).Append("\n"); + builder + .Append(options.Indent) + .Append(options.Indent) + .Append("true\n"); + + if (options.TransitivePinning) + { + builder + .Append(options.Indent) + .Append(options.Indent) + .Append( + "true\n" + ); + } + + builder.Append(options.Indent).Append("\n"); + + var ordered = versions + .OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var globalEntries = ordered.Where(p => globals.Contains(p.Key)).ToList(); + var normalEntries = ordered.Where(p => !globals.Contains(p.Key)).ToList(); + + if (normalEntries.Count > 0) + { + builder.Append(options.Indent).Append("\n"); + foreach (var (id, version) in normalEntries) + { + builder + .Append(options.Indent) + .Append(options.Indent) + .Append("\n"); + } + + builder.Append(options.Indent).Append("\n"); + } + + if (globalEntries.Count > 0) + { + builder.Append(options.Indent).Append("\n"); + foreach (var (id, version) in globalEntries) + { + builder + .Append(options.Indent) + .Append(options.Indent) + .Append("\n"); + } + + builder.Append(options.Indent).Append("\n"); + } + + builder.Append("\n"); + return builder.ToString(); + } + + /// + /// Merges resolved versions into an existing props file, adding what is missing and updating + /// what changed, without reformatting untouched regions. + /// + public static string Merge( + string existingContent, + IReadOnlyDictionary versions, + PackagesPropsOptions? options = null + ) + { + options ??= new PackagesPropsOptions(); + + if ( + string.IsNullOrWhiteSpace(existingContent) + || !MsBuildXml.TryParse(existingContent, out var document, out _) + || document.Root is null + ) + { + return Create(versions, options); + } + + var root = document.Root; + var ns = root.Name.Namespace; + + EnsureProperty(root, ns, "ManagePackageVersionsCentrally", "true", options.Indent); + if (options.TransitivePinning) + { + EnsureProperty( + root, + ns, + "CentralPackageTransitivePinningEnabled", + "true", + options.Indent + ); + } + + var existing = MsBuildXml + .Descendants(document, "PackageVersion") + .ToDictionary( + e => + MsBuildXml.AttributeValue(e, "Include") + ?? MsBuildXml.AttributeValue(e, "Update") + ?? string.Empty, + e => e, + StringComparer.OrdinalIgnoreCase + ); + + var missing = new List>(); + + foreach (var pair in versions.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase)) + { + if (!existing.TryGetValue(pair.Key, out var element)) + { + missing.Add(pair); + continue; + } + + var attribute = MsBuildXml.Attribute(element, "Version"); + if (attribute is not null) + { + if (!string.Equals(attribute.Value, pair.Value, StringComparison.OrdinalIgnoreCase)) + { + attribute.Value = pair.Value; + } + } + else + { + var child = MsBuildXml.Elements(element, "Version").FirstOrDefault(); + if (child is not null) + { + child.Value = pair.Value; + } + else + { + element.SetAttributeValue("Version", pair.Value); + } + } + } + + if (missing.Count > 0) + { + var itemGroup = MsBuildXml + .Elements(root, "ItemGroup") + .LastOrDefault(g => MsBuildXml.Elements(g, "PackageVersion").Any()); + + if (itemGroup is null) + { + itemGroup = new XElement(ns + "ItemGroup"); + root.Add(new XText("\n" + options.Indent), itemGroup, new XText("\n")); + } + + var indent = options.Indent + options.Indent; + + foreach (var (id, version) in missing) + { + var element = new XElement( + ns + "PackageVersion", + new XAttribute("Include", id), + new XAttribute("Version", version) + ); + + itemGroup.Add(new XText("\n" + indent), element); + } + + if (itemGroup.LastNode is not XText) + { + itemGroup.Add(new XText("\n" + options.Indent)); + } + } + + return MsBuildXml.ToString(document); + } + + private static void EnsureProperty( + XElement root, + XNamespace ns, + string name, + string value, + string indent + ) + { + var existing = MsBuildXml.Descendants(root, name).FirstOrDefault(); + if (existing is not null) + { + existing.Value = value; + return; + } + + var propertyGroup = MsBuildXml.Elements(root, "PropertyGroup").FirstOrDefault(); + if (propertyGroup is null) + { + propertyGroup = new XElement(ns + "PropertyGroup"); + root.AddFirst(new XText("\n" + indent), propertyGroup); + } + + propertyGroup.Add(new XText("\n" + indent + indent), new XElement(ns + name, value)); + if (propertyGroup.LastNode is not XText text || text.Value.Contains(name)) + { + propertyGroup.Add(new XText("\n" + indent)); + } + } + + private static string Escape(string value) => + value + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">") + .Replace("\"", """); +} diff --git a/CentralConfigGenerator.Core/IO/EncodingDetector.cs b/CentralConfigGenerator.Core/IO/EncodingDetector.cs new file mode 100644 index 0000000..e05b36f --- /dev/null +++ b/CentralConfigGenerator.Core/IO/EncodingDetector.cs @@ -0,0 +1,103 @@ +using System.Text; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.IO; + +/// +/// Detects the encoding, BOM presence and line endings of a file from its raw bytes. +/// +public static class EncodingDetector +{ + public static (string Content, TextFileFormat Format) Decode(byte[] bytes) + { + var (encoding, bomLength) = DetectEncoding(bytes); + var content = encoding.GetString(bytes, bomLength, bytes.Length - bomLength); + + var format = new TextFileFormat + { + Encoding = encoding, + HasByteOrderMark = bomLength > 0, + LineEnding = LineEndingStyleExtensions.Detect(content), + EndsWithNewLine = content.EndsWith('\n') || content.EndsWith('\r'), + }; + + return (content, format); + } + + public static byte[] Encode(string content, TextFileFormat format) + { + var text = LineEndingStyleExtensions.Normalize(content, format.LineEnding); + var encoding = format.ToWriteEncoding(); + var preamble = format.HasByteOrderMark ? encoding.GetPreamble() : []; + var body = encoding.GetBytes(text); + + if (preamble.Length == 0) + { + return body; + } + + var result = new byte[preamble.Length + body.Length]; + preamble.CopyTo(result, 0); + body.CopyTo(result, preamble.Length); + return result; + } + + /// + /// Resolves an IANA web name (for example utf-8 or windows-1252) to an encoding. + /// + public static Encoding? Resolve(string? webName) + { + if (string.IsNullOrWhiteSpace(webName)) + { + return null; + } + + try + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } + catch (InvalidOperationException) + { + // Provider already registered. + } + + try + { + return Encoding.GetEncoding(webName.Trim()); + } + catch (ArgumentException) + { + return null; + } + } + + private static (Encoding Encoding, int BomLength) DetectEncoding(byte[] bytes) + { + if (bytes.Length >= 4 && bytes[0] == 0xFF && bytes[1] == 0xFE && bytes[2] == 0 && bytes[3] == 0) + { + return (new UTF32Encoding(false, true), 4); + } + + if (bytes.Length >= 4 && bytes[0] == 0 && bytes[1] == 0 && bytes[2] == 0xFE && bytes[3] == 0xFF) + { + return (new UTF32Encoding(true, true), 4); + } + + if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) + { + return (new UTF8Encoding(true), 3); + } + + if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) + { + return (new UnicodeEncoding(false, true), 2); + } + + if (bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF) + { + return (new UnicodeEncoding(true, true), 2); + } + + return (new UTF8Encoding(false), 0); + } +} diff --git a/CentralConfigGenerator.Core/IO/IFileSystem.cs b/CentralConfigGenerator.Core/IO/IFileSystem.cs new file mode 100644 index 0000000..dc44d5b --- /dev/null +++ b/CentralConfigGenerator.Core/IO/IFileSystem.cs @@ -0,0 +1,114 @@ +using System.Text; + +namespace CentralConfigGenerator.Core.IO; + +/// +/// Thin file-system seam so every service in the core library stays testable. +/// +public interface IFileSystem +{ + bool FileExists(string path); + + bool DirectoryExists(string path); + + void CreateDirectory(string path); + + void DeleteFile(string path); + + void DeleteDirectory(string path, bool recursive); + + IReadOnlyList EnumerateFiles(string path, string searchPattern, bool recursive); + + IReadOnlyList EnumerateDirectories(string path); + + Task ReadAllBytesAsync(string path, CancellationToken cancellationToken = default); + + Task WriteAllBytesAsync(string path, byte[] bytes, CancellationToken cancellationToken = default); + + Task ReadAllTextAsync(string path, CancellationToken cancellationToken = default); + + Task WriteAllTextAsync(string path, string contents, CancellationToken cancellationToken = default); + + void CopyFile(string source, string destination, bool overwrite); + + DateTimeOffset GetLastWriteTime(string path); + + long GetFileSize(string path); +} + +/// Default backed by . +public sealed class PhysicalFileSystem : IFileSystem +{ + public bool FileExists(string path) => File.Exists(path); + + public bool DirectoryExists(string path) => Directory.Exists(path); + + public void CreateDirectory(string path) => Directory.CreateDirectory(path); + + public void DeleteFile(string path) => File.Delete(path); + + public void DeleteDirectory(string path, bool recursive) => Directory.Delete(path, recursive); + + public IReadOnlyList EnumerateFiles(string path, string searchPattern, bool recursive) => + Directory + .EnumerateFiles( + path, + searchPattern, + recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly + ) + .ToList(); + + public IReadOnlyList EnumerateDirectories(string path) => + Directory.EnumerateDirectories(path).ToList(); + + public Task ReadAllBytesAsync(string path, CancellationToken cancellationToken = default) => + File.ReadAllBytesAsync(path, cancellationToken); + + public async Task WriteAllBytesAsync( + string path, + byte[] bytes, + CancellationToken cancellationToken = default + ) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + await File.WriteAllBytesAsync(path, bytes, cancellationToken); + } + + public Task ReadAllTextAsync(string path, CancellationToken cancellationToken = default) => + File.ReadAllTextAsync(path, cancellationToken); + + public async Task WriteAllTextAsync( + string path, + string contents, + CancellationToken cancellationToken = default + ) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + await File.WriteAllTextAsync(path, contents, new UTF8Encoding(false), cancellationToken); + } + + public void CopyFile(string source, string destination, bool overwrite) + { + var directory = Path.GetDirectoryName(destination); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + File.Copy(source, destination, overwrite); + } + + public DateTimeOffset GetLastWriteTime(string path) => new FileInfo(path).LastWriteTimeUtc; + + public long GetFileSize(string path) => new FileInfo(path).Length; +} diff --git a/CentralConfigGenerator.Core/IO/TextFileFormat.cs b/CentralConfigGenerator.Core/IO/TextFileFormat.cs new file mode 100644 index 0000000..80d6e94 --- /dev/null +++ b/CentralConfigGenerator.Core/IO/TextFileFormat.cs @@ -0,0 +1,44 @@ +using System.Text; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.IO; + +/// +/// Captures the on-disk representation of a text file so it can be rewritten byte-faithfully. +/// +public sealed record TextFileFormat +{ + public static readonly TextFileFormat Default = new() + { + Encoding = new UTF8Encoding(false), + HasByteOrderMark = false, + LineEnding = LineEndingStyle.Preserve, + EndsWithNewLine = true, + }; + + public required Encoding Encoding { get; init; } + + public required bool HasByteOrderMark { get; init; } + + public required LineEndingStyle LineEnding { get; init; } + + public required bool EndsWithNewLine { get; init; } + + public Encoding ToWriteEncoding() + { + if (Encoding is UTF8Encoding) + { + return new UTF8Encoding(HasByteOrderMark); + } + + if (HasByteOrderMark) + { + return Encoding; + } + + // Strip the preamble for encodings that emit one by default (UTF-16/32). + return Encoding.GetPreamble().Length == 0 + ? Encoding + : (Encoding)new UTF8Encoding(false); + } +} diff --git a/CentralConfigGenerator.Core/Migration/IMigrationService.cs b/CentralConfigGenerator.Core/Migration/IMigrationService.cs new file mode 100644 index 0000000..1e73b82 --- /dev/null +++ b/CentralConfigGenerator.Core/Migration/IMigrationService.cs @@ -0,0 +1,26 @@ +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Migration; + +public interface IMigrationService +{ + /// Plans a migration to central package management without touching disk. + Task PlanAsync( + MigrationOptions options, + Func? conflictPrompt = null, + CancellationToken cancellationToken = default + ); + + /// Plans the reverse migration: central versions pushed back into project files. + Task PlanRevertAsync( + MigrationOptions options, + CancellationToken cancellationToken = default + ); + + /// Writes a plan to disk, taking a backup first unless disabled. + Task ApplyAsync( + MigrationPlan plan, + MigrationOptions options, + CancellationToken cancellationToken = default + ); +} diff --git a/CentralConfigGenerator.Core/Migration/MigrationOptions.cs b/CentralConfigGenerator.Core/Migration/MigrationOptions.cs new file mode 100644 index 0000000..008b010 --- /dev/null +++ b/CentralConfigGenerator.Core/Migration/MigrationOptions.cs @@ -0,0 +1,110 @@ +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Services; +using NuGet.Versioning; + +namespace CentralConfigGenerator.Core.Migration; + +/// Everything that shapes a migration to (or away from) central package management. +public sealed record MigrationOptions +{ + public string RootDirectory { get; init; } = Directory.GetCurrentDirectory(); + + public string? SolutionPath { get; init; } + + public string? ProjectPath { get; init; } + + /// Where Directory.Packages.props is written. Defaults to . + public string? OutputDirectory { get; init; } + + public string? ExcludePattern { get; init; } + + /// Merge into an existing Directory.Packages.props instead of replacing it. + public bool Merge { get; init; } + + /// Leave inline Version attributes in place (analysis-only migrations). + public bool KeepInlineVersions { get; init; } + + public VersionResolutionStrategy ConflictStrategy { get; init; } = + VersionResolutionStrategy.Highest; + + /// Fail the run instead of auto-resolving when projects disagree on a version. + public bool FailOnConflict { get; init; } + + public bool IgnorePrerelease { get; init; } + + public bool TransitivePinning { get; init; } + + public VersionComparison VersionComparison { get; init; } = VersionComparison.VersionRelease; + + public LineEndingStyle LineEnding { get; init; } = LineEndingStyle.Preserve; + + public string? EncodingName { get; init; } + + public bool CreateBackup { get; init; } = true; + + public string? BackupDirectory { get; init; } + + public bool AddGitIgnore { get; init; } + + /// Also promote shared project properties into Directory.Build.props. + public bool UnifyProperties { get; init; } + + public IReadOnlyList BuildProperties { get; init; } = + BuildPropertyDefaults.Recommended; + + public IReadOnlyList IncludedKinds { get; init; } = + [ProjectKind.CSharp, ProjectKind.FSharp, ProjectKind.VisualBasic]; + + public string ResolvedOutputDirectory => + Path.GetFullPath( + string.IsNullOrWhiteSpace(OutputDirectory) ? RootDirectory : OutputDirectory + ); + + public string PackagesPropsPath => + Path.Combine(ResolvedOutputDirectory, "Directory.Packages.props"); + + public string BuildPropsPath => Path.Combine(ResolvedOutputDirectory, "Directory.Build.props"); +} + +/// Property names that are safe and useful to hoist into Directory.Build.props. +public static class BuildPropertyDefaults +{ + public static readonly IReadOnlyList Recommended = + [ + "TargetFramework", + "TargetFrameworks", + "ImplicitUsings", + "Nullable", + "LangVersion", + "AnalysisLevel", + "EnforceCodeStyleInBuild", + "TreatWarningsAsErrors", + "GenerateDocumentationFile", + "InvariantGlobalization", + "Authors", + "Company", + "Product", + "Copyright", + "PackageLicenseExpression", + "PackageProjectUrl", + "RepositoryUrl", + "RepositoryType", + "Version", + "VersionPrefix", + ]; + + /// Properties that must never be hoisted because they are project-identity specific. + public static readonly IReadOnlyList NeverHoist = + [ + "AssemblyName", + "RootNamespace", + "OutputType", + "ProjectGuid", + "UserSecretsId", + "StartupObject", + "PackageId", + "ApplicationIcon", + "TargetFrameworkIdentifier", + "TargetFrameworkVersion", + ]; +} diff --git a/CentralConfigGenerator.Core/Migration/MigrationPlan.cs b/CentralConfigGenerator.Core/Migration/MigrationPlan.cs new file mode 100644 index 0000000..6a3e476 --- /dev/null +++ b/CentralConfigGenerator.Core/Migration/MigrationPlan.cs @@ -0,0 +1,75 @@ +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Migration; + +public enum FileChangeKind +{ + Created, + Modified, + Deleted, +} + +/// A single pending file write, kept in memory so dry-run and diff cost nothing extra. +public sealed record FileChange +{ + public required string Path { get; init; } + + public required FileChangeKind Kind { get; init; } + + public string OriginalContent { get; init; } = string.Empty; + + public string NewContent { get; init; } = string.Empty; + + public required IO.TextFileFormat Format { get; init; } + + public string Diff(int context = 3) => + UnifiedDiffFor(this, context); + + private static string UnifiedDiffFor(FileChange change, int context) => + Services.UnifiedDiff.Create( + change.OriginalContent, + change.NewContent, + change.Kind == FileChangeKind.Created ? "/dev/null" : change.Path, + change.Kind == FileChangeKind.Deleted ? "/dev/null" : change.Path, + context + ); +} + +/// A version disagreement between two or more projects. +public sealed record PackageConflict +{ + public required string PackageId { get; init; } + + public required IReadOnlyList References { get; init; } + + public required string ResolvedVersion { get; init; } + + public IEnumerable DistinctVersions => + References + .Select(r => r.Version) + .Where(v => !string.IsNullOrEmpty(v)) + .Select(v => v!) + .Distinct(StringComparer.OrdinalIgnoreCase); +} + +/// The complete, still-unapplied result of planning a migration. +public sealed record MigrationPlan +{ + public required IReadOnlyList Changes { get; init; } + + public required IReadOnlyDictionary ResolvedVersions { get; init; } + + public required IReadOnlyList Conflicts { get; init; } + + public required IReadOnlyList Findings { get; init; } + + public required int ProjectCount { get; init; } + + public int PackageCount => ResolvedVersions.Count; + + public bool HasChanges => Changes.Count > 0; + + public IReadOnlyList AffectedPaths => + Changes.Select(c => c.Path).ToList(); +} diff --git a/CentralConfigGenerator.Core/Migration/MigrationService.cs b/CentralConfigGenerator.Core/Migration/MigrationService.cs new file mode 100644 index 0000000..dcfac6f --- /dev/null +++ b/CentralConfigGenerator.Core/Migration/MigrationService.cs @@ -0,0 +1,452 @@ +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.Generators; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Services; +using CentralConfigGenerator.Core.Services.Abstractions; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Migration; + +/// +/// Plans and applies the migration between inline package versions and central package +/// management. Planning never writes to disk, which is what makes dry-run, diff and rollback +/// all fall out of the same code path. +/// +public sealed class MigrationService( + IProjectDiscoveryService discovery, + IFileSystem fileSystem, + IBackupService backupService, + VersionSelector versionSelector +) : IMigrationService +{ + public async Task PlanAsync( + MigrationOptions options, + Func? conflictPrompt = null, + CancellationToken cancellationToken = default + ) + { + var projects = await DiscoverAsync(options, cancellationToken); + if (projects.Count == 0) + { + return EmptyPlan(); + } + + var references = projects + .SelectMany(PackageReferenceReader.Read) + .Where(r => !string.IsNullOrWhiteSpace(r.Version)) + .ToList(); + + var grouped = references + .GroupBy(r => r.PackageId, StringComparer.OrdinalIgnoreCase) + .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); + var conflicts = new List(); + var findings = new List(); + + foreach (var group in grouped) + { + var versions = group + .Select(r => r.Version!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + string chosen; + + if (versions.Count == 1) + { + chosen = versions[0]; + } + else + { + var pending = new PackageConflict + { + PackageId = group.Key, + References = group.ToList(), + ResolvedVersion = string.Empty, + }; + + if (options.FailOnConflict && conflictPrompt is null) + { + throw new VersionConflictException(group.Key, versions); + } + + chosen = + conflictPrompt?.Invoke(pending) + ?? versionSelector.Select( + group.Key, + versions, + options.ConflictStrategy, + options.IgnorePrerelease, + options.VersionComparison + ); + + conflicts.Add(pending with { ResolvedVersion = chosen }); + + findings.Add( + new Finding + { + RuleId = RuleIds.VersionInconsistency, + Severity = Severity.Moderate, + PackageId = group.Key, + Version = chosen, + Message = + $"'{group.Key}' is referenced with {versions.Count} different versions ({string.Join(", ", versions)}).", + Recommendation = $"Unified on {chosen}.", + } + ); + } + + resolved[group.Key] = chosen; + } + + var globals = references + .Where(r => r.IsGlobal) + .Select(r => r.PackageId) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var changes = new List(); + + changes.Add( + await BuildPackagesPropsChangeAsync(options, resolved, globals, cancellationToken) + ); + + if (!options.KeepInlineVersions) + { + foreach (var project in projects) + { + cancellationToken.ThrowIfCancellationRequested(); + + var updated = ProjectRewriter.StripVersions(project, out _); + if (updated is not null) + { + changes.Add(ToChange(project, updated)); + } + } + } + + if (options.UnifyProperties) + { + changes.AddRange(await BuildPropertyChangesAsync(options, projects, cancellationToken)); + } + + return new MigrationPlan + { + Changes = changes, + ResolvedVersions = resolved, + Conflicts = conflicts, + Findings = findings, + ProjectCount = projects.Count, + }; + } + + public async Task PlanRevertAsync( + MigrationOptions options, + CancellationToken cancellationToken = default + ) + { + var projects = await DiscoverAsync(options, cancellationToken); + if (projects.Count == 0) + { + return EmptyPlan(); + } + + var propsPath = options.PackagesPropsPath; + var centralVersions = new Dictionary(StringComparer.OrdinalIgnoreCase); + var findings = new List(); + + if (fileSystem.FileExists(propsPath)) + { + var propsFile = await discovery.LoadAsync(propsPath, cancellationToken); + if ( + propsFile is not null + && MsBuildXml.TryParse(propsFile.Content, out var propsDocument, out _) + ) + { + foreach (var pair in PackageReferenceReader.ReadPackageVersions(propsDocument)) + { + centralVersions[pair.Key] = pair.Value; + } + } + } + + var changes = new List(); + + foreach (var project in projects) + { + cancellationToken.ThrowIfCancellationRequested(); + + var updated = ProjectRewriter.RestoreVersions(project, centralVersions, out var missing); + + foreach (var packageId in missing) + { + findings.Add( + new Finding + { + RuleId = RuleIds.MissingCentralVersion, + Severity = Severity.High, + PackageId = packageId, + ProjectPath = project.Path, + Message = + $"'{packageId}' has no PackageVersion entry, so no version could be restored.", + Recommendation = + "Add the package to Directory.Packages.props or set the version manually.", + } + ); + } + + if (updated is not null) + { + changes.Add(ToChange(project, updated)); + } + } + + if (fileSystem.FileExists(propsPath)) + { + var propsFile = await discovery.LoadAsync(propsPath, cancellationToken); + if (propsFile is not null) + { + changes.Add( + new FileChange + { + Path = propsPath, + Kind = FileChangeKind.Deleted, + OriginalContent = propsFile.Content, + NewContent = string.Empty, + Format = propsFile.Format, + } + ); + } + } + + return new MigrationPlan + { + Changes = changes, + ResolvedVersions = centralVersions, + Conflicts = [], + Findings = findings, + ProjectCount = projects.Count, + }; + } + + public async Task ApplyAsync( + MigrationPlan plan, + MigrationOptions options, + CancellationToken cancellationToken = default + ) + { + BackupSet? backup = null; + + if (options.CreateBackup && plan.HasChanges) + { + backup = await backupService.CreateAsync( + options.RootDirectory, + options.BackupDirectory ?? options.RootDirectory, + "migrate", + plan.AffectedPaths, + cancellationToken + ); + } + + if (options.AddGitIgnore) + { + await backupService.EnsureGitIgnoreAsync( + options.BackupDirectory ?? options.RootDirectory, + BackupService.DefaultBackupFolderName, + cancellationToken + ); + } + + foreach (var change in plan.Changes) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (change.Kind == FileChangeKind.Deleted) + { + if (fileSystem.FileExists(change.Path)) + { + fileSystem.DeleteFile(change.Path); + } + + continue; + } + + var format = ApplyFormatOverrides(change.Format, options); + var bytes = EncodingDetector.Encode(change.NewContent, format); + await fileSystem.WriteAllBytesAsync(change.Path, bytes, cancellationToken); + } + + return backup; + } + + private static TextFileFormat ApplyFormatOverrides( + TextFileFormat format, + MigrationOptions options + ) + { + var encoding = EncodingDetector.Resolve(options.EncodingName); + + return format with + { + Encoding = encoding ?? format.Encoding, + HasByteOrderMark = encoding is not null + ? encoding.GetPreamble().Length > 0 && encoding is not System.Text.UTF8Encoding + : format.HasByteOrderMark, + LineEnding = + options.LineEnding == LineEndingStyle.Preserve + ? format.LineEnding + : options.LineEnding, + }; + } + + private async Task> DiscoverAsync( + MigrationOptions options, + CancellationToken cancellationToken + ) => + await discovery.DiscoverAsync( + new DiscoveryOptions + { + RootDirectory = options.RootDirectory, + SolutionPath = options.SolutionPath, + ProjectPath = options.ProjectPath, + ExcludePattern = options.ExcludePattern, + IncludedKinds = options.IncludedKinds.ToList(), + }, + cancellationToken + ); + + private async Task BuildPackagesPropsChangeAsync( + MigrationOptions options, + IReadOnlyDictionary resolved, + IReadOnlyCollection globals, + CancellationToken cancellationToken + ) + { + var path = options.PackagesPropsPath; + var writerOptions = new PackagesPropsOptions + { + TransitivePinning = options.TransitivePinning, + GlobalPackages = globals, + }; + + if (fileSystem.FileExists(path)) + { + var existing = await discovery.LoadAsync(path, cancellationToken); + var original = existing?.Content ?? string.Empty; + + var content = options.Merge + ? PackagesPropsWriter.Merge(original, resolved, writerOptions) + : PackagesPropsWriter.Create(resolved, writerOptions); + + return new FileChange + { + Path = path, + Kind = FileChangeKind.Modified, + OriginalContent = original, + NewContent = content, + Format = existing?.Format ?? TextFileFormat.Default, + }; + } + + return new FileChange + { + Path = path, + Kind = FileChangeKind.Created, + OriginalContent = string.Empty, + NewContent = PackagesPropsWriter.Create(resolved, writerOptions), + Format = TextFileFormat.Default, + }; + } + + private async Task> BuildPropertyChangesAsync( + MigrationOptions options, + IReadOnlyList projects, + CancellationToken cancellationToken + ) + { + var hoisted = PropertyHoister.FindHoistableProperties(projects, options.BuildProperties); + if (hoisted.Count == 0) + { + return []; + } + + var changes = new List(); + var path = options.BuildPropsPath; + + if (fileSystem.FileExists(path)) + { + var existing = await discovery.LoadAsync(path, cancellationToken); + var original = existing?.Content ?? string.Empty; + + changes.Add( + new FileChange + { + Path = path, + Kind = FileChangeKind.Modified, + OriginalContent = original, + NewContent = BuildPropsWriter.Merge(original, hoisted, options.BuildProperties), + Format = existing?.Format ?? TextFileFormat.Default, + } + ); + } + else + { + changes.Add( + new FileChange + { + Path = path, + Kind = FileChangeKind.Created, + OriginalContent = string.Empty, + NewContent = BuildPropsWriter.Create(hoisted, options.BuildProperties), + Format = TextFileFormat.Default, + } + ); + } + + foreach (var project in projects) + { + var updated = ProjectRewriter.RemoveProperties(project, hoisted.Keys, hoisted); + if (updated is null) + { + continue; + } + + var existingChange = changes.FirstOrDefault(c => + string.Equals(c.Path, project.Path, StringComparison.OrdinalIgnoreCase) + ); + + if (existingChange is not null) + { + changes.Remove(existingChange); + } + + changes.Add(ToChange(project, updated)); + } + + return changes; + } + + private static FileChange ToChange(ProjectFile project, string updatedContent) => + new() + { + Path = project.Path, + Kind = FileChangeKind.Modified, + OriginalContent = project.Content, + NewContent = updatedContent, + Format = project.Format, + }; + + private static MigrationPlan EmptyPlan() => + new() + { + Changes = [], + ResolvedVersions = new Dictionary(), + Conflicts = [], + Findings = [], + ProjectCount = 0, + }; +} diff --git a/CentralConfigGenerator.Core/Migration/ProjectRewriter.cs b/CentralConfigGenerator.Core/Migration/ProjectRewriter.cs new file mode 100644 index 0000000..e6e4455 --- /dev/null +++ b/CentralConfigGenerator.Core/Migration/ProjectRewriter.cs @@ -0,0 +1,328 @@ +using System.Xml.Linq; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Migration; + +/// +/// Surgical edits to a single project file: stripping inline versions for a migration, putting +/// them back for a revert, and removing properties that moved to Directory.Build.props. +/// +public static class ProjectRewriter +{ + /// + /// Removes inline Version from every PackageReference. + /// Returns null when nothing changed or the file could not be parsed. + /// + public static string? StripVersions(ProjectFile projectFile, out IReadOnlyList touched) + { + touched = []; + + if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _)) + { + return null; + } + + var changed = false; + var packages = new List(); + + foreach (var element in MsBuildXml.Descendants(document, "PackageReference")) + { + var id = MsBuildXml.AttributeValue(element, "Include") + ?? MsBuildXml.AttributeValue(element, "Update"); + + var attribute = MsBuildXml.Attribute(element, "Version"); + if (attribute is not null) + { + attribute.Remove(); + changed = true; + if (id is not null) + { + packages.Add(id); + } + + continue; + } + + var child = MsBuildXml.Elements(element, "Version").FirstOrDefault(); + if (child is null) + { + continue; + } + + MsBuildXml.RemoveWithWhitespace(child); + CollapseIfEmpty(element); + changed = true; + if (id is not null) + { + packages.Add(id); + } + } + + touched = packages; + return changed ? MsBuildXml.ToString(document) : null; + } + + /// Removes the inline version of one specific package from a project. + public static string? StripVersion(ProjectFile projectFile, string packageId) + { + if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _)) + { + return null; + } + + var changed = false; + + foreach (var element in MsBuildXml.Descendants(document, "PackageReference")) + { + var id = + MsBuildXml.AttributeValue(element, "Include") + ?? MsBuildXml.AttributeValue(element, "Update"); + + if (!string.Equals(id, packageId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var attribute = MsBuildXml.Attribute(element, "Version"); + if (attribute is not null) + { + attribute.Remove(); + changed = true; + continue; + } + + var child = MsBuildXml.Elements(element, "Version").FirstOrDefault(); + if (child is not null) + { + MsBuildXml.RemoveWithWhitespace(child); + CollapseIfEmpty(element); + changed = true; + } + } + + return changed ? MsBuildXml.ToString(document) : null; + } + + /// + /// Writes versions back onto every PackageReference that lacks one, using the supplied + /// central version map. Used by the revert command. + /// + public static string? RestoreVersions( + ProjectFile projectFile, + IReadOnlyDictionary centralVersions, + out IReadOnlyList unresolved + ) + { + var missing = new List(); + unresolved = missing; + + if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _)) + { + return null; + } + + var changed = false; + + foreach (var element in MsBuildXml.Descendants(document, "PackageReference")) + { + if (MsBuildXml.Attribute(element, "Version") is not null) + { + continue; + } + + if (MsBuildXml.Elements(element, "Version").Any()) + { + continue; + } + + var id = + MsBuildXml.AttributeValue(element, "Include") + ?? MsBuildXml.AttributeValue(element, "Update"); + + if (string.IsNullOrWhiteSpace(id)) + { + continue; + } + + if (!centralVersions.TryGetValue(id, out var version)) + { + missing.Add(id); + continue; + } + + InsertVersionAfterInclude(element, version); + changed = true; + } + + return changed ? MsBuildXml.ToString(document) : null; + } + + /// Removes the named properties from a project because they were hoisted upwards. + public static string? RemoveProperties( + ProjectFile projectFile, + IReadOnlyCollection propertyNames, + IReadOnlyDictionary? onlyWhenValueMatches = null + ) + { + if (propertyNames.Count == 0) + { + return null; + } + + if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _)) + { + return null; + } + + var names = new HashSet(propertyNames, StringComparer.OrdinalIgnoreCase); + var changed = false; + + foreach (var group in MsBuildXml.Descendants(document, "PropertyGroup").ToList()) + { + // Conditioned property groups are project-specific; leave them alone. + if (!string.IsNullOrWhiteSpace(MsBuildXml.AttributeValue(group, "Condition"))) + { + continue; + } + + foreach (var element in group.Elements().ToList()) + { + if (!names.Contains(element.Name.LocalName)) + { + continue; + } + + if ( + onlyWhenValueMatches is not null + && onlyWhenValueMatches.TryGetValue(element.Name.LocalName, out var expected) + && !string.Equals(element.Value.Trim(), expected, StringComparison.Ordinal) + ) + { + continue; + } + + MsBuildXml.RemoveWithWhitespace(element); + changed = true; + } + + MsBuildXml.RemoveIfEmpty(group); + } + + return changed ? MsBuildXml.ToString(document) : null; + } + + /// Removes a duplicate PackageReference element for the given package id. + public static string? RemoveDuplicateReference(ProjectFile projectFile, string packageId) + { + if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _)) + { + return null; + } + + var matches = MsBuildXml + .Descendants(document, "PackageReference") + .Where(e => + string.Equals( + MsBuildXml.AttributeValue(e, "Include"), + packageId, + StringComparison.OrdinalIgnoreCase + ) + ) + .ToList(); + + if (matches.Count < 2) + { + return null; + } + + foreach (var duplicate in matches.Skip(1)) + { + var parent = duplicate.Parent; + MsBuildXml.RemoveWithWhitespace(duplicate); + MsBuildXml.RemoveIfEmpty(parent); + } + + return MsBuildXml.ToString(document); + } + + /// Rewrites a package id so its casing matches the canonical spelling from the feed. + public static string? NormalizeCasing(ProjectFile projectFile, string canonicalId) + { + if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _)) + { + return null; + } + + var changed = false; + + foreach (var element in MsBuildXml.Descendants(document, "PackageReference")) + { + var attribute = MsBuildXml.Attribute(element, "Include"); + if ( + attribute is null + || !string.Equals(attribute.Value, canonicalId, StringComparison.OrdinalIgnoreCase) + || string.Equals(attribute.Value, canonicalId, StringComparison.Ordinal) + ) + { + continue; + } + + attribute.Value = canonicalId; + changed = true; + } + + return changed ? MsBuildXml.ToString(document) : null; + } + + /// + /// Adds a Version attribute directly after Include. LINQ to XML has no attribute-insert API, + /// so the attribute list is rebuilt in the desired order. + /// + private static void InsertVersionAfterInclude(XElement element, string version) + { + var attributes = element.Attributes().ToList(); + element.RemoveAttributes(); + + var inserted = false; + + foreach (var attribute in attributes) + { + element.Add(new XAttribute(attribute.Name, attribute.Value)); + + if ( + inserted + || !string.Equals(attribute.Name.LocalName, "Include", StringComparison.OrdinalIgnoreCase) + && !string.Equals(attribute.Name.LocalName, "Update", StringComparison.OrdinalIgnoreCase) + ) + { + continue; + } + + element.Add(new XAttribute("Version", version)); + inserted = true; + } + + if (!inserted) + { + element.Add(new XAttribute("Version", version)); + } + } + + private static void CollapseIfEmpty(XElement element) + { + if (element.Elements().Any()) + { + return; + } + + // Drop leftover whitespace so \n becomes self-closing. + foreach (var node in element.Nodes().OfType().ToList()) + { + if (node.Value.Trim().Length == 0) + { + node.Remove(); + } + } + } +} diff --git a/CentralConfigGenerator.Core/Migration/PropertyHoister.cs b/CentralConfigGenerator.Core/Migration/PropertyHoister.cs new file mode 100644 index 0000000..8199c83 --- /dev/null +++ b/CentralConfigGenerator.Core/Migration/PropertyHoister.cs @@ -0,0 +1,100 @@ +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Migration; + +/// +/// Works out which MSBuild properties every project agrees on, so they can be lifted into +/// Directory.Build.props without changing the evaluated build. +/// +public static class PropertyHoister +{ + /// + /// Returns the properties whose value is identical across all projects that declare + /// them, and that are declared by at least two projects. Requiring unanimity is what keeps the + /// migration behaviour-preserving. + /// + public static Dictionary FindHoistableProperties( + IReadOnlyCollection projects, + IReadOnlyList candidateNames, + int minimumProjects = 2 + ) + { + var allowed = new HashSet(candidateNames, StringComparer.OrdinalIgnoreCase); + var blocked = new HashSet( + BuildPropertyDefaults.NeverHoist, + StringComparer.OrdinalIgnoreCase + ); + + var observed = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var declaringProjects = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var project in projects) + { + if (!MsBuildXml.TryParse(project.Content, out var document, out _)) + { + continue; + } + + var seenInThisProject = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var group in MsBuildXml.Descendants(document, "PropertyGroup")) + { + // Conditioned groups vary per configuration and are never safe to hoist. + if (!string.IsNullOrWhiteSpace(MsBuildXml.AttributeValue(group, "Condition"))) + { + continue; + } + + foreach (var element in group.Elements()) + { + var name = element.Name.LocalName; + + if (blocked.Contains(name) || (allowed.Count > 0 && !allowed.Contains(name))) + { + continue; + } + + var value = element.Value.Trim(); + if (value.Length == 0) + { + continue; + } + + if (!observed.TryGetValue(name, out var values)) + { + values = new HashSet(StringComparer.Ordinal); + observed[name] = values; + } + + values.Add(value); + seenInThisProject.Add(name); + } + } + + foreach (var name in seenInThisProject) + { + declaringProjects[name] = declaringProjects.GetValueOrDefault(name) + 1; + } + } + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var (name, values) in observed) + { + if (values.Count != 1) + { + continue; + } + + if (declaringProjects.GetValueOrDefault(name) < minimumProjects) + { + continue; + } + + result[name] = values.First(); + } + + return result; + } +} diff --git a/CentralConfigGenerator.Core/Models/BackupSet.cs b/CentralConfigGenerator.Core/Models/BackupSet.cs new file mode 100644 index 0000000..7253d42 --- /dev/null +++ b/CentralConfigGenerator.Core/Models/BackupSet.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; + +namespace CentralConfigGenerator.Core.Models; + +/// A single timestamped backup produced before a mutating operation. +public sealed record BackupSet +{ + public required string Id { get; init; } + + public required string Path { get; init; } + + public required DateTimeOffset CreatedAt { get; init; } + + public required string Operation { get; init; } + + public required IReadOnlyList Files { get; init; } + + [JsonIgnore] + public long SizeInBytes { get; init; } +} + +/// Manifest persisted alongside the copied files inside a backup directory. +public sealed record BackupManifest +{ + public string Id { get; init; } = string.Empty; + + public DateTimeOffset CreatedAt { get; init; } + + public string Operation { get; init; } = string.Empty; + + public string RootDirectory { get; init; } = string.Empty; + + public List Entries { get; init; } = []; +} + +public sealed record BackupEntry +{ + public string OriginalPath { get; init; } = string.Empty; + + public string RelativePath { get; init; } = string.Empty; + + /// True when the file did not exist before the operation and should be deleted on rollback. + public bool WasCreated { get; init; } +} diff --git a/CentralConfigGenerator.Core/Models/ExitCode.cs b/CentralConfigGenerator.Core/Models/ExitCode.cs new file mode 100644 index 0000000..05aedf1 --- /dev/null +++ b/CentralConfigGenerator.Core/Models/ExitCode.cs @@ -0,0 +1,32 @@ +namespace CentralConfigGenerator.Core.Models; + +/// Process exit codes, stable across releases so CI can branch on them. +public static class ExitCode +{ + public const int Success = 0; + public const int ValidationError = 1; + public const int FileOperationError = 2; + public const int VersionConflict = 3; + public const int NoProjectsFound = 4; + public const int AnalysisIssuesFound = 5; + public const int UnexpectedError = 6; + public const int TestFailure = 7; + public const int IncompleteAnalysis = 8; + public const int GraphDrift = 9; + + public static string Describe(int code) => + code switch + { + Success => "Success", + ValidationError => "ValidationError", + FileOperationError => "FileOperationError", + VersionConflict => "VersionConflict", + NoProjectsFound => "NoProjectsFound", + AnalysisIssuesFound => "AnalysisIssuesFound", + UnexpectedError => "UnexpectedError", + TestFailure => "TestFailure", + IncompleteAnalysis => "IncompleteAnalysis", + GraphDrift => "GraphDrift", + _ => "Unknown", + }; +} diff --git a/CentralConfigGenerator.Core/Models/LineEndingStyle.cs b/CentralConfigGenerator.Core/Models/LineEndingStyle.cs new file mode 100644 index 0000000..d8555c9 --- /dev/null +++ b/CentralConfigGenerator.Core/Models/LineEndingStyle.cs @@ -0,0 +1,82 @@ +namespace CentralConfigGenerator.Core.Models; + +/// +/// Line ending style used when writing files back to disk. +/// +public enum LineEndingStyle +{ + /// Keep whatever the original file used. + Preserve = 0, + Lf, + CrLf, + Cr, +} + +public static class LineEndingStyleExtensions +{ + public static string ToNewLine(this LineEndingStyle style) => + style switch + { + LineEndingStyle.Lf => "\n", + LineEndingStyle.CrLf => "\r\n", + LineEndingStyle.Cr => "\r", + _ => Environment.NewLine, + }; + + public static LineEndingStyle Detect(string content) + { + var crlf = 0; + var lf = 0; + var cr = 0; + + for (var i = 0; i < content.Length; i++) + { + var c = content[i]; + if (c == '\r') + { + if (i + 1 < content.Length && content[i + 1] == '\n') + { + crlf++; + i++; + } + else + { + cr++; + } + } + else if (c == '\n') + { + lf++; + } + } + + if (crlf == 0 && lf == 0 && cr == 0) + { + return LineEndingStyle.Preserve; + } + + if (crlf >= lf && crlf >= cr) + { + return LineEndingStyle.CrLf; + } + + return lf >= cr ? LineEndingStyle.Lf : LineEndingStyle.Cr; + } + + public static string Normalize(string content, LineEndingStyle style) + { + if (style == LineEndingStyle.Preserve) + { + return content; + } + + var unified = content.Replace("\r\n", "\n").Replace('\r', '\n'); + return style switch + { + LineEndingStyle.Lf => unified, + LineEndingStyle.CrLf => unified.Replace("\n", "\r\n"), + LineEndingStyle.Cr => unified.Replace('\n', '\r'), + _ => content, + }; + } +} diff --git a/CentralConfigGenerator.Core/Models/PackageReferenceInfo.cs b/CentralConfigGenerator.Core/Models/PackageReferenceInfo.cs new file mode 100644 index 0000000..44c4da2 --- /dev/null +++ b/CentralConfigGenerator.Core/Models/PackageReferenceInfo.cs @@ -0,0 +1,26 @@ +namespace CentralConfigGenerator.Core.Models; + +/// +/// A single PackageReference (or GlobalPackageReference) as declared by a project. +/// +public sealed record PackageReferenceInfo +{ + public required string PackageId { get; init; } + + /// The declared version string, or null when the reference has no version (CPM style). + public string? Version { get; init; } + + public required string ProjectPath { get; init; } + + /// The MSBuild Condition on the reference or its item group, when present. + public string? Condition { get; init; } + + /// True when the version came from a child element rather than an attribute. + public bool VersionIsElement { get; init; } + + /// True for GlobalPackageReference items. + public bool IsGlobal { get; init; } + + /// True when the reference targets a private/development dependency. + public bool IsPrivateAssets { get; init; } +} diff --git a/CentralConfigGenerator.Core/Models/ProjectFile.cs b/CentralConfigGenerator.Core/Models/ProjectFile.cs index b2d6782..dfc0508 100644 --- a/CentralConfigGenerator.Core/Models/ProjectFile.cs +++ b/CentralConfigGenerator.Core/Models/ProjectFile.cs @@ -1,8 +1,27 @@ -namespace CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.IO; +namespace CentralConfigGenerator.Core.Models; + +/// +/// An MSBuild file that was read from disk, together with everything needed to write it back +/// without disturbing its original encoding or line endings. +/// public record ProjectFile { public string Path { get; set; } = string.Empty; - + public string Content { get; set; } = string.Empty; -} \ No newline at end of file + + /// The kind of MSBuild file, inferred from the extension when not set explicitly. + public ProjectKind Kind { get; set; } = ProjectKind.Unknown; + + /// The original on-disk text format. Defaults to BOM-less UTF-8. + public TextFileFormat Format { get; set; } = TextFileFormat.Default; + + public ProjectKind ResolvedKind => + Kind != ProjectKind.Unknown ? Kind : ProjectKindExtensions.FromPath(Path); + + public string FileName => System.IO.Path.GetFileName(Path); + + public string DirectoryPath => System.IO.Path.GetDirectoryName(Path) ?? string.Empty; +} diff --git a/CentralConfigGenerator.Core/Models/ProjectKind.cs b/CentralConfigGenerator.Core/Models/ProjectKind.cs new file mode 100644 index 0000000..0048954 --- /dev/null +++ b/CentralConfigGenerator.Core/Models/ProjectKind.cs @@ -0,0 +1,31 @@ +namespace CentralConfigGenerator.Core.Models; + +/// +/// The kind of MSBuild file that was discovered on disk. +/// +public enum ProjectKind +{ + Unknown = 0, + CSharp, + FSharp, + VisualBasic, + Props, + Targets, +} + +public static class ProjectKindExtensions +{ + public static ProjectKind FromPath(string path) => + Path.GetExtension(path).ToLowerInvariant() switch + { + ".csproj" => ProjectKind.CSharp, + ".fsproj" => ProjectKind.FSharp, + ".vbproj" => ProjectKind.VisualBasic, + ".props" => ProjectKind.Props, + ".targets" => ProjectKind.Targets, + _ => ProjectKind.Unknown, + }; + + public static bool IsProject(this ProjectKind kind) => + kind is ProjectKind.CSharp or ProjectKind.FSharp or ProjectKind.VisualBasic; +} diff --git a/CentralConfigGenerator.Core/NuGet/INuGetFeedService.cs b/CentralConfigGenerator.Core/NuGet/INuGetFeedService.cs new file mode 100644 index 0000000..e1fffbd --- /dev/null +++ b/CentralConfigGenerator.Core/NuGet/INuGetFeedService.cs @@ -0,0 +1,21 @@ +namespace CentralConfigGenerator.Core.NuGet; + +public interface INuGetFeedService +{ + /// Fetches metadata for a single package, memoised for the lifetime of the service. + Task GetMetadataAsync( + string packageId, + CancellationToken cancellationToken = default + ); + + /// Fetches metadata for many packages with bounded parallelism. + Task> GetMetadataAsync( + IEnumerable packageIds, + int maxParallelism, + IProgress? progress = null, + CancellationToken cancellationToken = default + ); + + /// The configured package sources, for diagnostics. + IReadOnlyList Sources { get; } +} diff --git a/CentralConfigGenerator.Core/NuGet/NuGetFeedService.cs b/CentralConfigGenerator.Core/NuGet/NuGetFeedService.cs new file mode 100644 index 0000000..91aaf64 --- /dev/null +++ b/CentralConfigGenerator.Core/NuGet/NuGetFeedService.cs @@ -0,0 +1,234 @@ +using System.Collections.Concurrent; +using CentralConfigGenerator.Core.Analysis; +using NuGet.Common; +using NuGet.Configuration; +using NuGet.Protocol; +using NuGet.Protocol.Core.Types; +using NuGet.Versioning; + +namespace CentralConfigGenerator.Core.NuGet; + +/// +/// Talks to every package source configured for the workspace (nuget.config aware), collecting +/// version lists, deprecation notices, licence data and security advisories. +/// +public sealed class NuGetFeedService : INuGetFeedService, IDisposable +{ + private readonly List _repositories = []; + private readonly SourceCacheContext _cache = new(); + private readonly ConcurrentDictionary> _memo = + new(StringComparer.OrdinalIgnoreCase); + + public NuGetFeedService(string? rootDirectory = null) + { + var sources = LoadSources(rootDirectory); + + foreach (var source in sources) + { + _repositories.Add(Repository.Factory.GetCoreV3(source)); + } + + if (_repositories.Count == 0) + { + _repositories.Add( + Repository.Factory.GetCoreV3( + new PackageSource("https://api.nuget.org/v3/index.json") + ) + ); + } + + Sources = _repositories.Select(r => r.PackageSource.Source).ToList(); + } + + public IReadOnlyList Sources { get; } + + public Task GetMetadataAsync( + string packageId, + CancellationToken cancellationToken = default + ) => _memo.GetOrAdd(packageId, id => FetchAsync(id, cancellationToken)); + + public async Task> GetMetadataAsync( + IEnumerable packageIds, + int maxParallelism, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + var ids = packageIds + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var results = new ConcurrentDictionary( + StringComparer.OrdinalIgnoreCase + ); + + using var throttle = new SemaphoreSlim(Math.Max(1, maxParallelism)); + + var tasks = ids.Select(async id => + { + await throttle.WaitAsync(cancellationToken); + try + { + results[id] = await GetMetadataAsync(id, cancellationToken); + progress?.Report(id); + } + finally + { + throttle.Release(); + } + }); + + await Task.WhenAll(tasks); + return results; + } + + private async Task FetchAsync( + string packageId, + CancellationToken cancellationToken + ) + { + Exception? lastError = null; + + foreach (var repository in _repositories) + { + try + { + var resource = await repository.GetResourceAsync( + cancellationToken + ); + + var metadata = ( + await resource.GetMetadataAsync( + packageId, + includePrerelease: true, + includeUnlisted: false, + _cache, + NullLogger.Instance, + cancellationToken + ) + ).ToList(); + + if (metadata.Count == 0) + { + continue; + } + + return await BuildSnapshotAsync(packageId, metadata, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + lastError = ex; + } + } + + return lastError is not null + ? PackageMetadataSnapshot.Unavailable(packageId, lastError.Message) + : new PackageMetadataSnapshot { PackageId = packageId, Found = false }; + } + + private static async Task BuildSnapshotAsync( + string packageId, + List metadata, + CancellationToken cancellationToken + ) + { + var ordered = metadata.OrderByDescending(m => m.Identity.Version).ToList(); + var newest = ordered[0]; + + var deprecation = await SafeGetDeprecationAsync(newest); + var vulnerabilities = new List(); + + foreach (var entry in ordered) + { + if (entry.Vulnerabilities is null) + { + continue; + } + + foreach (var vulnerability in entry.Vulnerabilities) + { + vulnerabilities.Add( + new PackageVulnerability + { + Severity = MapSeverity(vulnerability.Severity), + AdvisoryUrl = vulnerability.AdvisoryUrl?.ToString() ?? string.Empty, + AffectedRange = entry.Identity.Version.ToNormalizedString(), + } + ); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + + return new PackageMetadataSnapshot + { + PackageId = newest.Identity.Id, + Found = true, + AllVersions = ordered.Select(m => m.Identity.Version).ToList(), + LatestStable = ordered.FirstOrDefault(m => !m.Identity.Version.IsPrerelease)?.Identity.Version, + LatestIncludingPrerelease = newest.Identity.Version, + IsDeprecated = deprecation is not null, + DeprecationMessage = deprecation?.Message, + DeprecationReasons = deprecation?.Reasons?.ToList() ?? [], + AlternatePackageId = deprecation?.AlternatePackage?.PackageId, + License = DescribeLicense(newest), + LicenseUrl = newest.LicenseUrl?.ToString(), + ProjectUrl = newest.ProjectUrl?.ToString(), + Vulnerabilities = vulnerabilities, + }; + } + + private static async Task SafeGetDeprecationAsync( + IPackageSearchMetadata metadata + ) + { + try + { + return await metadata.GetDeprecationMetadataAsync(); + } + catch (Exception) + { + return null; + } + } + + private static string? DescribeLicense(IPackageSearchMetadata metadata) + { + if (metadata.LicenseMetadata is { } license) + { + return string.IsNullOrWhiteSpace(license.License) + ? license.Type.ToString() + : license.License; + } + + return metadata.LicenseUrl?.ToString(); + } + + private static Severity MapSeverity(int severity) => + severity switch + { + >= 3 => Severity.Critical, + 2 => Severity.High, + 1 => Severity.Moderate, + _ => Severity.Low, + }; + + private static List LoadSources(string? rootDirectory) + { + try + { + var settings = Settings.LoadDefaultSettings( + rootDirectory ?? Directory.GetCurrentDirectory() + ); + var provider = new PackageSourceProvider(settings); + return provider.LoadPackageSources().Where(s => s.IsEnabled).ToList(); + } + catch (Exception) + { + return []; + } + } + + public void Dispose() => _cache.Dispose(); +} diff --git a/CentralConfigGenerator.Core/NuGet/PackageGraph.cs b/CentralConfigGenerator.Core/NuGet/PackageGraph.cs new file mode 100644 index 0000000..482b2a7 --- /dev/null +++ b/CentralConfigGenerator.Core/NuGet/PackageGraph.cs @@ -0,0 +1,63 @@ +namespace CentralConfigGenerator.Core.NuGet; + +/// A resolved dependency as reported by dotnet list package. +public sealed record ResolvedPackage +{ + public required string PackageId { get; init; } + + public required string ResolvedVersion { get; init; } + + public string? RequestedVersion { get; init; } + + public required string ProjectPath { get; init; } + + public required string TargetFramework { get; init; } + + public bool IsTransitive { get; init; } +} + +/// The full package graph for a workspace, keyed for quick lookups. +public sealed record PackageGraph +{ + public static readonly PackageGraph Empty = new() + { + Packages = [], + IsAvailable = false, + Error = "Package graph was not collected.", + }; + + public required IReadOnlyList Packages { get; init; } + + public bool IsAvailable { get; init; } + + public string? Error { get; init; } + + public IEnumerable Direct => Packages.Where(p => !p.IsTransitive); + + public IEnumerable Transitive => Packages.Where(p => p.IsTransitive); + + /// Package ids resolved to more than one version across the workspace. + public IEnumerable> DivergentPackages => + Packages + .GroupBy(p => p.PackageId, StringComparer.OrdinalIgnoreCase) + .Where(g => + g.Select(p => p.ResolvedVersion) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count() > 1 + ); + + /// A stable signature of the resolved graph, used by verify to detect drift. + public IReadOnlyDictionary Signature() => + Packages + .GroupBy( + p => $"{NormalizeProject(p.ProjectPath)}|{p.TargetFramework}|{p.PackageId}", + StringComparer.OrdinalIgnoreCase + ) + .ToDictionary( + g => g.Key, + g => g.OrderBy(p => p.ResolvedVersion, StringComparer.OrdinalIgnoreCase).Last().ResolvedVersion, + StringComparer.OrdinalIgnoreCase + ); + + private static string NormalizeProject(string path) => Path.GetFileName(path); +} diff --git a/CentralConfigGenerator.Core/NuGet/PackageGraphService.cs b/CentralConfigGenerator.Core/NuGet/PackageGraphService.cs new file mode 100644 index 0000000..c65712d --- /dev/null +++ b/CentralConfigGenerator.Core/NuGet/PackageGraphService.cs @@ -0,0 +1,162 @@ +using System.Text.Json; +using CentralConfigGenerator.Core.Services; + +namespace CentralConfigGenerator.Core.NuGet; + +public interface IPackageGraphService +{ + Task LoadAsync( + string target, + string workingDirectory, + bool includeTransitive, + CancellationToken cancellationToken = default + ); +} + +/// +/// Builds the resolved package graph by shelling out to dotnet list package --format json, +/// which is the only source of truth for transitive resolution. +/// +public sealed class PackageGraphService(IDotNetCliService cli) : IPackageGraphService +{ + public async Task LoadAsync( + string target, + string workingDirectory, + bool includeTransitive, + CancellationToken cancellationToken = default + ) + { + if (!cli.IsAvailable) + { + return PackageGraph.Empty with { Error = "The dotnet CLI was not found on PATH." }; + } + + var arguments = $"list \"{target}\" package --format json"; + if (includeTransitive) + { + arguments += " --include-transitive"; + } + + var result = await cli.RunAsync( + arguments, + workingDirectory, + TimeSpan.FromMinutes(5), + cancellationToken + ); + + if (!result.Succeeded) + { + return PackageGraph.Empty with + { + Error = Summarize(result.CombinedOutput), + }; + } + + return Parse(result.StandardOutput); + } + + internal static PackageGraph Parse(string json) + { + var start = json.IndexOf('{'); + if (start < 0) + { + return PackageGraph.Empty with { Error = "dotnet list package returned no JSON." }; + } + + var packages = new List(); + + try + { + using var document = JsonDocument.Parse(json[start..]); + + if (!document.RootElement.TryGetProperty("projects", out var projects)) + { + return PackageGraph.Empty with { Error = "Unexpected dotnet list package output." }; + } + + foreach (var project in projects.EnumerateArray()) + { + var projectPath = project.TryGetProperty("path", out var path) + ? path.GetString() ?? string.Empty + : string.Empty; + + if (!project.TryGetProperty("frameworks", out var frameworks)) + { + continue; + } + + foreach (var framework in frameworks.EnumerateArray()) + { + var tfm = framework.TryGetProperty("framework", out var name) + ? name.GetString() ?? string.Empty + : string.Empty; + + AddPackages(framework, "topLevelPackages", false); + AddPackages(framework, "transitivePackages", true); + + void AddPackages(JsonElement source, string property, bool transitive) + { + if (!source.TryGetProperty(property, out var list)) + { + return; + } + + foreach (var entry in list.EnumerateArray()) + { + var id = entry.TryGetProperty("id", out var idValue) + ? idValue.GetString() + : null; + + var resolved = entry.TryGetProperty("resolvedVersion", out var rv) + ? rv.GetString() + : null; + + if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(resolved)) + { + continue; + } + + packages.Add( + new ResolvedPackage + { + PackageId = id, + ResolvedVersion = resolved, + RequestedVersion = entry.TryGetProperty( + "requestedVersion", + out var requested + ) + ? requested.GetString() + : null, + ProjectPath = projectPath, + TargetFramework = tfm, + IsTransitive = transitive, + } + ); + } + } + } + } + } + catch (JsonException ex) + { + return PackageGraph.Empty with { Error = ex.Message }; + } + + return new PackageGraph { Packages = packages, IsAvailable = true }; + } + + private static string Summarize(string output) + { + var lines = output + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(l => l.Trim()) + .Where(l => l.Length > 0) + .ToList(); + + var error = lines.FirstOrDefault(l => + l.Contains("error", StringComparison.OrdinalIgnoreCase) + ); + + return error ?? (lines.Count > 0 ? lines[^1] : "dotnet list package failed."); + } +} diff --git a/CentralConfigGenerator.Core/NuGet/PackageMetadataSnapshot.cs b/CentralConfigGenerator.Core/NuGet/PackageMetadataSnapshot.cs new file mode 100644 index 0000000..c979de9 --- /dev/null +++ b/CentralConfigGenerator.Core/NuGet/PackageMetadataSnapshot.cs @@ -0,0 +1,55 @@ +using CentralConfigGenerator.Core.Analysis; +using NuGetVersioning = NuGet.Versioning; + +namespace CentralConfigGenerator.Core.NuGet; + +/// Everything the analyzers need to know about a package, fetched once per run. +public sealed record PackageMetadataSnapshot +{ + public required string PackageId { get; init; } + + public bool Found { get; init; } + + public IReadOnlyList AllVersions { get; init; } = []; + + public NuGetVersioning.NuGetVersion? LatestStable { get; init; } + + public NuGetVersioning.NuGetVersion? LatestIncludingPrerelease { get; init; } + + public bool IsDeprecated { get; init; } + + public string? DeprecationMessage { get; init; } + + public IReadOnlyList DeprecationReasons { get; init; } = []; + + public string? AlternatePackageId { get; init; } + + public string? License { get; init; } + + public string? LicenseUrl { get; init; } + + public string? ProjectUrl { get; init; } + + public IReadOnlyList Vulnerabilities { get; init; } = []; + + /// Set when the feed could not be reached; the run is then reported as incomplete. + public string? Error { get; init; } + + public static PackageMetadataSnapshot Unavailable(string packageId, string error) => + new() + { + PackageId = packageId, + Found = false, + Error = error, + }; +} + +public sealed record PackageVulnerability +{ + public required Severity Severity { get; init; } + + public required string AdvisoryUrl { get; init; } + + /// Version range the advisory applies to, when the feed reports one. + public string? AffectedRange { get; init; } +} diff --git a/CentralConfigGenerator.Core/Reporting/CsvReportWriter.cs b/CentralConfigGenerator.Core/Reporting/CsvReportWriter.cs new file mode 100644 index 0000000..fe935c4 --- /dev/null +++ b/CentralConfigGenerator.Core/Reporting/CsvReportWriter.cs @@ -0,0 +1,43 @@ +using System.Text; +using CentralConfigGenerator.Core.Analysis; + +namespace CentralConfigGenerator.Core.Reporting; + +/// One row per finding, for spreadsheets and ad-hoc pivots. +public sealed class CsvReportWriter : IReportWriter +{ + public ReportFormat Format => ReportFormat.Csv; + + public string Write(AnalysisReport report) + { + var builder = new StringBuilder(); + builder.Append("RuleId,Severity,PackageId,Version,Project,Message,Recommendation,AutoFixable,Fingerprint\n"); + + foreach (var finding in report.Findings) + { + builder + .Append(Quote(finding.RuleId)).Append(',') + .Append(Quote(finding.Severity.ToString())).Append(',') + .Append(Quote(finding.PackageId)).Append(',') + .Append(Quote(finding.Version)).Append(',') + .Append(Quote(ReportPaths.Relative(finding.ProjectPath, report.RootDirectory))).Append(',') + .Append(Quote(finding.Message)).Append(',') + .Append(Quote(finding.Recommendation)).Append(',') + .Append(finding.IsAutoFixable ? "true" : "false").Append(',') + .Append(Quote(finding.Fingerprint)).Append('\n'); + } + + return builder.ToString(); + } + + private static string Quote(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + var escaped = value.Replace("\"", "\"\"").Replace("\r", " ").Replace("\n", " "); + return $"\"{escaped}\""; + } +} diff --git a/CentralConfigGenerator.Core/Reporting/IReportWriter.cs b/CentralConfigGenerator.Core/Reporting/IReportWriter.cs new file mode 100644 index 0000000..0d261bf --- /dev/null +++ b/CentralConfigGenerator.Core/Reporting/IReportWriter.cs @@ -0,0 +1,23 @@ +using CentralConfigGenerator.Core.Analysis; + +namespace CentralConfigGenerator.Core.Reporting; + +public interface IReportWriter +{ + ReportFormat Format { get; } + + string Write(AnalysisReport report); +} + +public static class ReportWriterFactory +{ + public static IReportWriter Create(ReportFormat format) => + format switch + { + ReportFormat.Json => new JsonReportWriter(), + ReportFormat.Sarif => new SarifReportWriter(), + ReportFormat.Markdown => new MarkdownReportWriter(), + ReportFormat.Csv => new CsvReportWriter(), + _ => new JsonReportWriter(), + }; +} diff --git a/CentralConfigGenerator.Core/Reporting/JsonReportWriter.cs b/CentralConfigGenerator.Core/Reporting/JsonReportWriter.cs new file mode 100644 index 0000000..31f825b --- /dev/null +++ b/CentralConfigGenerator.Core/Reporting/JsonReportWriter.cs @@ -0,0 +1,69 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using CentralConfigGenerator.Core.Analysis; + +namespace CentralConfigGenerator.Core.Reporting; + +/// Machine-readable report; the shape is stable and safe to script against. +public sealed class JsonReportWriter : IReportWriter +{ + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Converters = { new JsonStringEnumConverter() }, + }; + + public ReportFormat Format => ReportFormat.Json; + + public string Write(AnalysisReport report) + { + var payload = new + { + success = report.HighestSeverity < Severity.High, + summary = new + { + healthScore = report.HealthScore, + grade = report.Grade, + projectCount = report.ProjectCount, + packageCount = report.PackageCount, + findingCount = report.Findings.Count, + suppressedCount = report.SuppressedCount, + durationMs = (long)report.Duration.TotalMilliseconds, + incomplete = report.IsIncomplete, + bySeverity = new + { + critical = report.CountOf(Severity.Critical), + high = report.CountOf(Severity.High), + moderate = report.CountOf(Severity.Moderate), + low = report.CountOf(Severity.Low), + info = report.CountOf(Severity.Info), + }, + byRule = report + .Findings.GroupBy(f => f.RuleId) + .OrderByDescending(g => g.Count()) + .ToDictionary(g => g.Key, g => g.Count()), + }, + incompletions = report.Incompletions, + findings = report + .Findings.Select(f => new + { + ruleId = f.RuleId, + severity = f.Severity.ToString(), + message = f.Message, + packageId = f.PackageId, + version = f.Version, + project = ReportPaths.Relative(f.ProjectPath, report.RootDirectory), + recommendation = f.Recommendation, + autoFixable = f.IsAutoFixable, + fingerprint = f.Fingerprint, + }) + .ToList(), + }; + + return JsonSerializer.Serialize(payload, Options); + } +} diff --git a/CentralConfigGenerator.Core/Reporting/MarkdownReportWriter.cs b/CentralConfigGenerator.Core/Reporting/MarkdownReportWriter.cs new file mode 100644 index 0000000..dae9e61 --- /dev/null +++ b/CentralConfigGenerator.Core/Reporting/MarkdownReportWriter.cs @@ -0,0 +1,113 @@ +using System.Text; +using CentralConfigGenerator.Core.Analysis; + +namespace CentralConfigGenerator.Core.Reporting; + +/// Verdict-first Markdown, sized for a GitHub step summary or a PR comment. +public sealed class MarkdownReportWriter : IReportWriter +{ + public ReportFormat Format => ReportFormat.Markdown; + + public string Write(AnalysisReport report) + { + var builder = new StringBuilder(); + var verdict = report.HighestSeverity switch + { + Severity.Critical => "❌ Critical issues found", + Severity.High => "❌ High severity issues found", + Severity.Moderate => "⚠️ Moderate issues found", + Severity.Low or Severity.Info => "✅ Minor findings only", + _ => "✅ No issues found", + }; + + builder.Append("## Dependency health: ").Append(report.HealthScore).Append("/100 (") + .Append(report.Grade).Append(")\n\n"); + builder.Append(verdict).Append("\n\n"); + + builder.Append("| Metric | Value |\n|---|---|\n"); + builder.Append("| Projects | ").Append(report.ProjectCount).Append(" |\n"); + builder.Append("| Packages | ").Append(report.PackageCount).Append(" |\n"); + builder.Append("| Findings | ").Append(report.Findings.Count).Append(" |\n"); + + if (report.SuppressedCount > 0) + { + builder.Append("| Suppressed by baseline | ").Append(report.SuppressedCount).Append(" |\n"); + } + + builder.Append("| Duration | ").Append($"{report.Duration.TotalSeconds:F1}s").Append(" |\n\n"); + + if (report.Findings.Count == 0) + { + builder.Append("No findings. 🎉\n"); + return builder.ToString(); + } + + builder.Append("| Severity | Count |\n|---|---|\n"); + foreach (var severity in new[] { Severity.Critical, Severity.High, Severity.Moderate, Severity.Low, Severity.Info }) + { + var count = report.CountOf(severity); + if (count > 0) + { + builder.Append("| ").Append(Icon(severity)).Append(' ').Append(severity) + .Append(" | ").Append(count).Append(" |\n"); + } + } + + builder.Append('\n'); + + foreach (var group in report.Findings.GroupBy(f => f.Severity).OrderByDescending(g => g.Key)) + { + builder.Append("= Severity.High) + { + builder.Append(" open"); + } + + builder.Append(">").Append(Icon(group.Key)).Append(' ').Append(group.Key) + .Append(" (").Append(group.Count()).Append(")\n\n"); + + foreach (var finding in group) + { + builder.Append("- **").Append(finding.RuleId).Append("** — ") + .Append(Escape(finding.Message)); + + if (finding.Recommendation is not null) + { + builder.Append(' ').Append(Escape(finding.Recommendation)); + } + + if (finding.ProjectPath is not null) + { + builder.Append(" `").Append(Path.GetFileName(finding.ProjectPath)).Append('`'); + } + + builder.Append('\n'); + } + + builder.Append("\n\n\n"); + } + + if (report.IsIncomplete) + { + builder.Append("> ⚠️ The scan did not complete fully:\n"); + foreach (var incompletion in report.Incompletions) + { + builder.Append("> - ").Append(Escape(incompletion)).Append('\n'); + } + } + + return builder.ToString(); + } + + private static string Icon(Severity severity) => + severity switch + { + Severity.Critical => "🔴", + Severity.High => "🟠", + Severity.Moderate => "🟡", + Severity.Low => "🔵", + _ => "⚪", + }; + + private static string Escape(string value) => value.Replace("|", "\\|"); +} diff --git a/CentralConfigGenerator.Core/Reporting/ReportFormat.cs b/CentralConfigGenerator.Core/Reporting/ReportFormat.cs new file mode 100644 index 0000000..9da4b21 --- /dev/null +++ b/CentralConfigGenerator.Core/Reporting/ReportFormat.cs @@ -0,0 +1,11 @@ +namespace CentralConfigGenerator.Core.Reporting; + +/// Output formats supported by every reporting command. +public enum ReportFormat +{ + Terminal, + Json, + Sarif, + Markdown, + Csv, +} diff --git a/CentralConfigGenerator.Core/Reporting/ReportPaths.cs b/CentralConfigGenerator.Core/Reporting/ReportPaths.cs new file mode 100644 index 0000000..fecf529 --- /dev/null +++ b/CentralConfigGenerator.Core/Reporting/ReportPaths.cs @@ -0,0 +1,34 @@ +namespace CentralConfigGenerator.Core.Reporting; + +/// Path formatting shared by the report writers. +public static class ReportPaths +{ + /// + /// Renders a path relative to the scanned root with forward slashes, so reports are stable + /// across machines and diff cleanly in CI. + /// + public static string? Relative(string? path, string? root) + { + if (string.IsNullOrEmpty(path)) + { + return null; + } + + if (string.IsNullOrEmpty(root)) + { + return path; + } + + try + { + var relative = Path.GetRelativePath(root, path); + return relative.StartsWith("..", StringComparison.Ordinal) + ? path + : relative.Replace(Path.DirectorySeparatorChar, '/'); + } + catch (ArgumentException) + { + return path; + } + } +} diff --git a/CentralConfigGenerator.Core/Reporting/SarifReportWriter.cs b/CentralConfigGenerator.Core/Reporting/SarifReportWriter.cs new file mode 100644 index 0000000..018151b --- /dev/null +++ b/CentralConfigGenerator.Core/Reporting/SarifReportWriter.cs @@ -0,0 +1,111 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using CentralConfigGenerator.Core.Analysis; + +namespace CentralConfigGenerator.Core.Reporting; + +/// +/// SARIF 2.1.0 output, so findings render as inline annotations when uploaded with +/// github/codeql-action/upload-sarif. +/// +public sealed class SarifReportWriter : IReportWriter +{ + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + public ReportFormat Format => ReportFormat.Sarif; + + public string Write(AnalysisReport report) + { + var usedRules = report + .Findings.Select(f => f.RuleId) + .Distinct(StringComparer.Ordinal) + .Select(RuleCatalog.Find) + .Where(r => r is not null) + .Select(r => new + { + id = r!.RuleId, + name = r.Title, + shortDescription = new { text = r.Title }, + fullDescription = new { text = r.Description }, + help = new { text = r.Rationale, markdown = $"**{r.Title}**\n\n{r.Description}\n\n{r.Rationale}" }, + defaultConfiguration = new { level = ToSarifLevel(r.DefaultSeverity) }, + properties = new { tags = new[] { "dependencies", "nuget" } }, + }) + .ToList(); + + var payload = new + { + version = "2.1.0", + schema = "https://json.schemastore.org/sarif-2.1.0.json", + runs = new[] + { + new + { + tool = new + { + driver = new + { + name = "CentralConfigGenerator", + informationUri = "https://github.com/TarasKovalenko/CentralConfigGenerator", + version = ToolVersion, + rules = usedRules, + }, + }, + results = report + .Findings.Select(f => new + { + ruleId = f.RuleId, + level = ToSarifLevel(f.Severity), + message = new + { + text = f.Recommendation is null + ? f.Message + : $"{f.Message} {f.Recommendation}", + }, + partialFingerprints = new { centralConfig = f.Fingerprint }, + locations = new[] + { + new + { + physicalLocation = new + { + artifactLocation = new + { + uri = ToUri(f.ProjectPath, report.RootDirectory), + }, + region = new { startLine = Math.Max(1, f.Line) }, + }, + }, + }, + }) + .ToList(), + }, + }, + }; + + // The SARIF schema property is "$schema", which C# cannot express as an identifier. + return JsonSerializer.Serialize(payload, Options).Replace("\"schema\":", "\"$schema\":"); + } + + private static string ToolVersion => + typeof(SarifReportWriter).Assembly.GetName().Version?.ToString(3) ?? "1.0.0"; + + private static string ToSarifLevel(Severity severity) => + severity switch + { + Severity.Critical or Severity.High => "error", + Severity.Moderate => "warning", + Severity.Low => "note", + _ => "note", + }; + + private static string ToUri(string? path, string root) => + ReportPaths.Relative( + path, + string.IsNullOrWhiteSpace(root) ? Directory.GetCurrentDirectory() : root + ) ?? "Directory.Packages.props"; +} diff --git a/CentralConfigGenerator.Core/Services/Abstractions/IBackupService.cs b/CentralConfigGenerator.Core/Services/Abstractions/IBackupService.cs new file mode 100644 index 0000000..5cb6943 --- /dev/null +++ b/CentralConfigGenerator.Core/Services/Abstractions/IBackupService.cs @@ -0,0 +1,37 @@ +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Services.Abstractions; + +public interface IBackupService +{ + Task CreateAsync( + string rootDirectory, + string backupDirectory, + string operation, + IEnumerable files, + CancellationToken cancellationToken = default + ); + + IReadOnlyList List(string backupDirectory); + + Task RestoreLatestAsync( + string backupDirectory, + CancellationToken cancellationToken = default + ); + + Task RestoreAsync( + string backupDirectory, + string backupId, + CancellationToken cancellationToken = default + ); + + IReadOnlyList Prune(string backupDirectory, int retention); + + IReadOnlyList PruneAll(string backupDirectory); + + Task EnsureGitIgnoreAsync( + string gitIgnoreDirectory, + string backupDirectoryName, + CancellationToken cancellationToken = default + ); +} diff --git a/CentralConfigGenerator.Core/Services/BackupService.cs b/CentralConfigGenerator.Core/Services/BackupService.cs new file mode 100644 index 0000000..b584107 --- /dev/null +++ b/CentralConfigGenerator.Core/Services/BackupService.cs @@ -0,0 +1,275 @@ +using System.Text.Json; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Services.Abstractions; + +namespace CentralConfigGenerator.Core.Services; + +/// +/// Creates timestamped backups of every file an operation is about to touch, and restores them +/// on demand. Backups live under .centralconfig-backups/<timestamp>. +/// +public sealed class BackupService(IFileSystem fileSystem) : IBackupService +{ + public const string DefaultBackupFolderName = ".centralconfig-backups"; + private const string ManifestFileName = "manifest.json"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public async Task CreateAsync( + string rootDirectory, + string backupDirectory, + string operation, + IEnumerable files, + CancellationToken cancellationToken = default + ) + { + var paths = files.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + if (paths.Count == 0) + { + return null; + } + + var id = DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss-fff"); + var root = ResolveRoot(backupDirectory); + var target = Path.Combine(root, id); + fileSystem.CreateDirectory(target); + + var manifest = new BackupManifest + { + Id = id, + CreatedAt = DateTimeOffset.UtcNow, + Operation = operation, + RootDirectory = Path.GetFullPath(rootDirectory), + }; + + foreach (var path in paths) + { + cancellationToken.ThrowIfCancellationRequested(); + + var full = Path.GetFullPath(path); + var relative = MakeRelative(manifest.RootDirectory, full); + var destination = Path.Combine(target, relative); + + if (fileSystem.FileExists(full)) + { + fileSystem.CopyFile(full, destination, overwrite: true); + manifest.Entries.Add( + new BackupEntry { OriginalPath = full, RelativePath = relative } + ); + } + else + { + manifest.Entries.Add( + new BackupEntry + { + OriginalPath = full, + RelativePath = relative, + WasCreated = true, + } + ); + } + } + + await fileSystem.WriteAllTextAsync( + Path.Combine(target, ManifestFileName), + JsonSerializer.Serialize(manifest, JsonOptions), + cancellationToken + ); + + return ToBackupSet(manifest, target); + } + + public IReadOnlyList List(string backupDirectory) + { + var root = ResolveRoot(backupDirectory); + if (!fileSystem.DirectoryExists(root)) + { + return []; + } + + var results = new List(); + + foreach (var directory in fileSystem.EnumerateDirectories(root)) + { + var manifest = ReadManifest(directory); + if (manifest is not null) + { + results.Add(ToBackupSet(manifest, directory)); + } + } + + return results.OrderByDescending(b => b.CreatedAt).ToList(); + } + + public Task RestoreLatestAsync( + string backupDirectory, + CancellationToken cancellationToken = default + ) + { + var latest = List(backupDirectory).FirstOrDefault(); + return latest is null + ? Task.FromResult(null) + : RestoreAsync(backupDirectory, latest.Id, cancellationToken); + } + + public async Task RestoreAsync( + string backupDirectory, + string backupId, + CancellationToken cancellationToken = default + ) + { + var root = ResolveRoot(backupDirectory); + var target = Path.Combine(root, backupId); + var manifest = ReadManifest(target); + + if (manifest is null) + { + return null; + } + + foreach (var entry in manifest.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (entry.WasCreated) + { + if (fileSystem.FileExists(entry.OriginalPath)) + { + fileSystem.DeleteFile(entry.OriginalPath); + } + + continue; + } + + var source = Path.Combine(target, entry.RelativePath); + if (fileSystem.FileExists(source)) + { + fileSystem.CopyFile(source, entry.OriginalPath, overwrite: true); + } + } + + await Task.CompletedTask; + return ToBackupSet(manifest, target); + } + + public IReadOnlyList Prune(string backupDirectory, int retention) + { + if (retention < 0) + { + retention = 0; + } + + var all = List(backupDirectory); + var removed = all.Skip(retention).ToList(); + + foreach (var backup in removed) + { + fileSystem.DeleteDirectory(backup.Path, recursive: true); + } + + return removed; + } + + public IReadOnlyList PruneAll(string backupDirectory) => Prune(backupDirectory, 0); + + public async Task EnsureGitIgnoreAsync( + string gitIgnoreDirectory, + string backupDirectoryName, + CancellationToken cancellationToken = default + ) + { + var path = Path.Combine(gitIgnoreDirectory, ".gitignore"); + var entry = backupDirectoryName.TrimEnd('/') + "/"; + + var existing = fileSystem.FileExists(path) + ? await fileSystem.ReadAllTextAsync(path, cancellationToken) + : string.Empty; + + var lines = existing.Replace("\r\n", "\n").Split('\n'); + if (lines.Any(l => string.Equals(l.Trim(), entry, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + var separator = existing.Length == 0 || existing.EndsWith('\n') ? string.Empty : "\n"; + var content = + $"{existing}{separator}\n# CentralConfigGenerator backups\n{entry}\n"; + + await fileSystem.WriteAllTextAsync(path, content, cancellationToken); + } + + private static string ResolveRoot(string backupDirectory) + { + var full = Path.GetFullPath(backupDirectory); + return string.Equals( + Path.GetFileName(full), + DefaultBackupFolderName, + StringComparison.OrdinalIgnoreCase + ) + ? full + : Path.Combine(full, DefaultBackupFolderName); + } + + private static string MakeRelative(string root, string path) + { + var relative = Path.GetRelativePath(root, path); + + // Files outside the scanned root are flattened so they still land inside the backup. + if (relative.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relative)) + { + var sanitized = path.Replace(Path.VolumeSeparatorChar, '_') + .Replace(Path.DirectorySeparatorChar, '_') + .Replace(Path.AltDirectorySeparatorChar, '_'); + return Path.Combine("_external", sanitized); + } + + return relative; + } + + private BackupManifest? ReadManifest(string directory) + { + var manifestPath = Path.Combine(directory, ManifestFileName); + if (!fileSystem.FileExists(manifestPath)) + { + return null; + } + + try + { + var json = fileSystem.ReadAllTextAsync(manifestPath).GetAwaiter().GetResult(); + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (Exception ex) when (ex is JsonException or IOException) + { + return null; + } + } + + private BackupSet ToBackupSet(BackupManifest manifest, string path) + { + long size = 0; + foreach (var entry in manifest.Entries.Where(e => !e.WasCreated)) + { + var file = Path.Combine(path, entry.RelativePath); + if (fileSystem.FileExists(file)) + { + size += fileSystem.GetFileSize(file); + } + } + + return new BackupSet + { + Id = manifest.Id, + Path = path, + CreatedAt = manifest.CreatedAt, + Operation = manifest.Operation, + Files = manifest.Entries.Select(e => e.OriginalPath).ToList(), + SizeInBytes = size, + }; + } +} diff --git a/CentralConfigGenerator.Core/Services/DotNetCliService.cs b/CentralConfigGenerator.Core/Services/DotNetCliService.cs new file mode 100644 index 0000000..9fe1aa3 --- /dev/null +++ b/CentralConfigGenerator.Core/Services/DotNetCliService.cs @@ -0,0 +1,245 @@ +using System.Diagnostics; +using System.Text; + +namespace CentralConfigGenerator.Core.Services; + +/// Result of running a dotnet sub-command. +public sealed record ProcessResult +{ + public required int ExitCode { get; init; } + + public required string StandardOutput { get; init; } + + public required string StandardError { get; init; } + + public required TimeSpan Duration { get; init; } + + public bool Succeeded => ExitCode == 0; + + public string CombinedOutput => + string.IsNullOrWhiteSpace(StandardError) + ? StandardOutput + : StandardOutput + Environment.NewLine + StandardError; +} + +public interface IDotNetCliService +{ + bool IsAvailable { get; } + + Task RunAsync( + string arguments, + string workingDirectory, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default + ); + + Task RestoreAsync( + string target, + string workingDirectory, + CancellationToken cancellationToken = default + ); + + Task BuildAsync( + string target, + string workingDirectory, + CancellationToken cancellationToken = default + ); + + Task TestAsync( + string target, + string workingDirectory, + string? filter = null, + CancellationToken cancellationToken = default + ); +} + +/// Runs the local dotnet CLI, used for restores, package graphs, builds and tests. +public sealed class DotNetCliService : IDotNetCliService +{ + private bool? _available; + + public bool IsAvailable + { + get + { + _available ??= Probe(); + return _available.Value; + } + } + + public async Task RunAsync( + string arguments, + string workingDirectory, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default + ) + { + var stopwatch = Stopwatch.StartNew(); + + var startInfo = new ProcessStartInfo("dotnet") + { + Arguments = arguments, + WorkingDirectory = Directory.Exists(workingDirectory) + ? workingDirectory + : Directory.GetCurrentDirectory(), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + // Keep output machine-parsable and free of MSBuild's terminal logger escape codes. + startInfo.Environment["DOTNET_CLI_UI_LANGUAGE"] = "en"; + startInfo.Environment["DOTNET_NOLOGO"] = "1"; + startInfo.Environment["TERM"] = "dumb"; + + using var process = new Process { StartInfo = startInfo }; + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + + process.OutputDataReceived += (_, e) => + { + if (e.Data is not null) + { + stdout.AppendLine(e.Data); + } + }; + + process.ErrorDataReceived += (_, e) => + { + if (e.Data is not null) + { + stderr.AppendLine(e.Data); + } + }; + + try + { + process.Start(); + } + catch (Exception ex) + { + return new ProcessResult + { + ExitCode = -1, + StandardOutput = string.Empty, + StandardError = ex.Message, + Duration = stopwatch.Elapsed, + }; + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using var timeoutSource = timeout.HasValue + ? new CancellationTokenSource(timeout.Value) + : new CancellationTokenSource(); + + using var linked = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutSource.Token + ); + + try + { + await process.WaitForExitAsync(linked.Token); + } + catch (OperationCanceledException) + { + TryKill(process); + + return new ProcessResult + { + ExitCode = -1, + StandardOutput = stdout.ToString(), + StandardError = stderr.ToString() + Environment.NewLine + "Process timed out.", + Duration = stopwatch.Elapsed, + }; + } + + return new ProcessResult + { + ExitCode = process.ExitCode, + StandardOutput = stdout.ToString(), + StandardError = stderr.ToString(), + Duration = stopwatch.Elapsed, + }; + } + + public Task RestoreAsync( + string target, + string workingDirectory, + CancellationToken cancellationToken = default + ) => RunAsync($"restore \"{target}\"", workingDirectory, TimeSpan.FromMinutes(10), cancellationToken); + + public Task BuildAsync( + string target, + string workingDirectory, + CancellationToken cancellationToken = default + ) => + RunAsync( + $"build \"{target}\" --nologo -v quiet", + workingDirectory, + TimeSpan.FromMinutes(15), + cancellationToken + ); + + public Task TestAsync( + string target, + string workingDirectory, + string? filter = null, + CancellationToken cancellationToken = default + ) + { + var arguments = $"test \"{target}\" --nologo -v quiet"; + if (!string.IsNullOrWhiteSpace(filter)) + { + arguments += $" --filter \"{filter}\""; + } + + return RunAsync(arguments, workingDirectory, TimeSpan.FromMinutes(30), cancellationToken); + } + + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (Exception) + { + // The process already exited or cannot be killed; nothing useful to do. + } + } + + private static bool Probe() + { + try + { + using var process = Process.Start( + new ProcessStartInfo("dotnet", "--version") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + } + ); + + if (process is null) + { + return false; + } + + process.WaitForExit(10_000); + return process.HasExited && process.ExitCode == 0; + } + catch (Exception) + { + return false; + } + } +} diff --git a/CentralConfigGenerator.Core/Services/UnifiedDiff.cs b/CentralConfigGenerator.Core/Services/UnifiedDiff.cs new file mode 100644 index 0000000..404c4f7 --- /dev/null +++ b/CentralConfigGenerator.Core/Services/UnifiedDiff.cs @@ -0,0 +1,203 @@ +using System.Text; + +namespace CentralConfigGenerator.Core.Services; + +/// +/// Minimal unified-diff generator (Myers-style LCS) used by --dry-run --diff. +/// +public static class UnifiedDiff +{ + public static string Create( + string oldText, + string newText, + string oldLabel, + string newLabel, + int context = 3 + ) + { + var oldLines = SplitLines(oldText); + var newLines = SplitLines(newText); + var hunks = BuildHunks(oldLines, newLines, context); + + if (hunks.Count == 0) + { + return string.Empty; + } + + var builder = new StringBuilder(); + builder.Append("--- ").Append(oldLabel).Append('\n'); + builder.Append("+++ ").Append(newLabel).Append('\n'); + + foreach (var hunk in hunks) + { + builder + .Append("@@ -") + .Append(hunk.OldStart + 1) + .Append(',') + .Append(hunk.OldCount) + .Append(" +") + .Append(hunk.NewStart + 1) + .Append(',') + .Append(hunk.NewCount) + .Append(" @@\n"); + + foreach (var line in hunk.Lines) + { + builder.Append(line).Append('\n'); + } + } + + return builder.ToString(); + } + + private static string[] SplitLines(string text) => + text.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + + private sealed record Hunk(int OldStart, int OldCount, int NewStart, int NewCount, List Lines); + + private static List BuildHunks(string[] oldLines, string[] newLines, int context) + { + var operations = Diff(oldLines, newLines); + var hunks = new List(); + + var index = 0; + while (index < operations.Count) + { + if (operations[index].Kind == OpKind.Equal) + { + index++; + continue; + } + + var start = Math.Max(0, index - context); + var end = index; + while (end < operations.Count) + { + if (operations[end].Kind != OpKind.Equal) + { + end++; + continue; + } + + // Extend across short runs of equal lines so nearby edits share a hunk. + var runEnd = end; + while (runEnd < operations.Count && operations[runEnd].Kind == OpKind.Equal) + { + runEnd++; + } + + if (runEnd - end > context * 2 || runEnd == operations.Count) + { + end = Math.Min(operations.Count, end + context); + break; + } + + end = runEnd; + } + + var lines = new List(); + int oldStart = -1, + newStart = -1, + oldCount = 0, + newCount = 0; + + for (var i = start; i < end; i++) + { + var op = operations[i]; + oldStart = oldStart < 0 && op.OldIndex >= 0 ? op.OldIndex : oldStart; + newStart = newStart < 0 && op.NewIndex >= 0 ? op.NewIndex : newStart; + + switch (op.Kind) + { + case OpKind.Equal: + lines.Add(" " + op.Text); + oldCount++; + newCount++; + break; + case OpKind.Delete: + lines.Add("-" + op.Text); + oldCount++; + break; + case OpKind.Insert: + lines.Add("+" + op.Text); + newCount++; + break; + } + } + + hunks.Add( + new Hunk( + Math.Max(0, oldStart), + oldCount, + Math.Max(0, newStart), + newCount, + lines + ) + ); + index = end; + } + + return hunks; + } + + private enum OpKind + { + Equal, + Delete, + Insert, + } + + private sealed record Op(OpKind Kind, string Text, int OldIndex, int NewIndex); + + private static List Diff(string[] a, string[] b) + { + var lengths = new int[a.Length + 1, b.Length + 1]; + for (var i = a.Length - 1; i >= 0; i--) + { + for (var j = b.Length - 1; j >= 0; j--) + { + lengths[i, j] = a[i] == b[j] + ? lengths[i + 1, j + 1] + 1 + : Math.Max(lengths[i + 1, j], lengths[i, j + 1]); + } + } + + var operations = new List(); + int x = 0, + y = 0; + + while (x < a.Length && y < b.Length) + { + if (a[x] == b[y]) + { + operations.Add(new Op(OpKind.Equal, a[x], x, y)); + x++; + y++; + } + else if (lengths[x + 1, y] >= lengths[x, y + 1]) + { + operations.Add(new Op(OpKind.Delete, a[x], x, -1)); + x++; + } + else + { + operations.Add(new Op(OpKind.Insert, b[y], -1, y)); + y++; + } + } + + while (x < a.Length) + { + operations.Add(new Op(OpKind.Delete, a[x], x, -1)); + x++; + } + + while (y < b.Length) + { + operations.Add(new Op(OpKind.Insert, b[y], -1, y)); + y++; + } + + return operations; + } +} diff --git a/CentralConfigGenerator.Core/Services/VersionConflictException.cs b/CentralConfigGenerator.Core/Services/VersionConflictException.cs new file mode 100644 index 0000000..a675b53 --- /dev/null +++ b/CentralConfigGenerator.Core/Services/VersionConflictException.cs @@ -0,0 +1,16 @@ +namespace CentralConfigGenerator.Core.Services; + +/// +/// Thrown when projects disagree on a package version and the caller asked for the +/// strategy. +/// +public sealed class VersionConflictException(string packageId, IReadOnlyList versions) + : Exception( + $"Version conflict for '{packageId}': {string.Join(", ", versions)}. " + + "Resolve it manually or choose a different conflict strategy." + ) +{ + public string PackageId { get; } = packageId; + + public IReadOnlyList Versions { get; } = versions; +} diff --git a/CentralConfigGenerator.Core/Services/VersionConflictResolver.cs b/CentralConfigGenerator.Core/Services/VersionConflictResolver.cs index 9ad5eb8..b7429b8 100644 --- a/CentralConfigGenerator.Core/Services/VersionConflictResolver.cs +++ b/CentralConfigGenerator.Core/Services/VersionConflictResolver.cs @@ -6,10 +6,20 @@ namespace CentralConfigGenerator.Core.Services; public enum VersionResolutionStrategy { + /// Pick the highest version any project asks for. The safe default. Highest, + + /// Pick the lowest version, keeping the migration as conservative as possible. Lowest, + + /// Pick whichever version the most projects already use. MostCommon, + + /// Refuse to guess and let the caller decide. Manual, + + /// Abort the run when projects disagree. + Fail, } public class VersionConflictResolver : IVersionConflictResolver @@ -50,6 +60,10 @@ VersionResolutionStrategy strategy VersionResolutionStrategy.Manual => throw new InvalidOperationException( $"Manual resolution required for package '{packageName}'. Versions found: {string.Join(", ", versionList)}" ), + VersionResolutionStrategy.Fail => throw new VersionConflictException( + packageName, + versionList + ), _ => throw new ArgumentOutOfRangeException(nameof(strategy)), }; } diff --git a/CentralConfigGenerator.Core/Services/VersionSelector.cs b/CentralConfigGenerator.Core/Services/VersionSelector.cs new file mode 100644 index 0000000..4dbd49b --- /dev/null +++ b/CentralConfigGenerator.Core/Services/VersionSelector.cs @@ -0,0 +1,90 @@ +using CentralConfigGenerator.Core.Services.Abstractions; +using NuGet.Versioning; + +namespace CentralConfigGenerator.Core.Services; + +/// +/// Chooses a single version from the set a package is referenced with, applying pre-release +/// filtering and the caller's conflict strategy. +/// +public sealed class VersionSelector(IVersionConflictResolver resolver) +{ + public string Select( + string packageId, + IReadOnlyList versions, + VersionResolutionStrategy strategy, + bool ignorePrerelease, + VersionComparison comparison = VersionComparison.VersionRelease + ) + { + var candidates = versions + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (candidates.Count == 0) + { + throw new ArgumentException( + $"No versions supplied for package '{packageId}'.", + nameof(versions) + ); + } + + if (ignorePrerelease) + { + var stable = candidates.Where(v => !IsPrerelease(v)).ToList(); + + // Only drop pre-releases when at least one stable version remains. + if (stable.Count > 0) + { + candidates = stable; + } + } + + if (candidates.Count == 1) + { + return candidates[0]; + } + + // Highest/Lowest are the only strategies where the comparison scope changes the answer: + // it decides whether pre-release tags and build metadata participate in the ordering. + if (strategy is VersionResolutionStrategy.Highest or VersionResolutionStrategy.Lowest) + { + var ordered = candidates + .Where(v => NuGetVersion.TryParse(v, out _)) + .OrderBy(v => v, Comparer.Create((a, b) => Compare(a, b, comparison))) + .ToList(); + + if (ordered.Count > 0) + { + return strategy == VersionResolutionStrategy.Highest ? ordered[^1] : ordered[0]; + } + } + + return resolver.Resolve(packageId, candidates, strategy); + } + + /// Compares two version strings using the requested NuGet comparison scope. + public static int Compare(string left, string right, VersionComparison comparison) + { + var hasLeft = NuGetVersion.TryParse(left, out var leftVersion); + var hasRight = NuGetVersion.TryParse(right, out var rightVersion); + + if (hasLeft && hasRight) + { + return new VersionComparer(comparison).Compare(leftVersion, rightVersion); + } + + return string.Compare(left, right, StringComparison.OrdinalIgnoreCase); + } + + public static bool IsPrerelease(string version) => + NuGetVersion.TryParse(version, out var parsed) && parsed.IsPrerelease; + + public static bool IsFloating(string version) => + version.Contains('*', StringComparison.Ordinal) + || ( + VersionRange.TryParse(version, out var range) + && (range.IsFloating || !range.HasUpperBound && version.StartsWith('[')) + ); +} diff --git a/CentralConfigGenerator.Core/Workspace/BatchService.cs b/CentralConfigGenerator.Core/Workspace/BatchService.cs new file mode 100644 index 0000000..72120d1 --- /dev/null +++ b/CentralConfigGenerator.Core/Workspace/BatchService.cs @@ -0,0 +1,190 @@ +using System.Collections.Concurrent; +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Workspace; + +public sealed record BatchOptions +{ + public required string RootDirectory { get; init; } + + /// Process repositories concurrently. + public bool Parallel { get; init; } + + /// Keep going after a repository fails instead of stopping at the first error. + public bool ContinueOnError { get; init; } + + public int MaxParallelism { get; init; } = 4; + + public string? ExcludePattern { get; init; } + + /// Maximum directory depth to search for solutions. + public int MaxDepth { get; init; } = 4; +} + +public sealed record BatchItemResult +{ + public required string Workspace { get; init; } + + public required bool Succeeded { get; init; } + + public MigrationPlan? Plan { get; init; } + + public string? Error { get; init; } +} + +public sealed record BatchResult +{ + public required IReadOnlyList Items { get; init; } + + public int SucceededCount => Items.Count(i => i.Succeeded); + + public int FailedCount => Items.Count(i => !i.Succeeded); +} + +/// +/// Runs an operation across every solution in a monorepo. Each workspace is independent, so +/// failures are isolated and (optionally) skipped rather than aborting the batch. +/// +public sealed class BatchService(IFileSystem fileSystem) +{ + /// Finds each distinct solution directory beneath the root. + public IReadOnlyList DiscoverWorkspaces(BatchOptions options) + { + var root = Path.GetFullPath(options.RootDirectory); + + if (!fileSystem.DirectoryExists(root)) + { + return []; + } + + var excludeRegex = string.IsNullOrWhiteSpace(options.ExcludePattern) + ? null + : new System.Text.RegularExpressions.Regex( + options.ExcludePattern, + System.Text.RegularExpressions.RegexOptions.IgnoreCase + ); + + var solutions = fileSystem + .EnumerateFiles(root, "*", recursive: true) + .Where(SolutionReader.IsSolution) + .Where(path => Depth(root, path) <= options.MaxDepth) + .Where(path => !IsInExcludedDirectory(root, path, excludeRegex)) + .Select(path => Path.GetDirectoryName(path)!) + .Where(d => !string.IsNullOrEmpty(d)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(d => d, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (solutions.Count > 0) + { + return solutions; + } + + // No solutions: fall back to directories that directly contain project files. + return fileSystem + .EnumerateFiles(root, "*", recursive: true) + .Where(p => ProjectKindExtensions.FromPath(p).IsProject()) + .Where(path => !IsInExcludedDirectory(root, path, excludeRegex)) + .Select(path => Path.GetDirectoryName(path)!) + .Where(d => !string.IsNullOrEmpty(d)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(d => d, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public async Task RunAsync( + BatchOptions options, + Func> operation, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + var workspaces = DiscoverWorkspaces(options); + var results = new ConcurrentBag(); + + if (options.Parallel) + { + await Parallel.ForEachAsync( + workspaces, + new ParallelOptions + { + MaxDegreeOfParallelism = Math.Max(1, options.MaxParallelism), + CancellationToken = cancellationToken, + }, + async (workspace, token) => + { + results.Add(await RunOneAsync(workspace, operation, progress, token)); + } + ); + } + else + { + foreach (var workspace in workspaces) + { + var result = await RunOneAsync(workspace, operation, progress, cancellationToken); + results.Add(result); + + if (!result.Succeeded && !options.ContinueOnError) + { + break; + } + } + } + + return new BatchResult + { + Items = results.OrderBy(r => r.Workspace, StringComparer.OrdinalIgnoreCase).ToList(), + }; + } + + private static async Task RunOneAsync( + string workspace, + Func> operation, + IProgress? progress, + CancellationToken cancellationToken + ) + { + progress?.Report(workspace); + + try + { + var plan = await operation(workspace, cancellationToken); + return new BatchItemResult + { + Workspace = workspace, + Succeeded = true, + Plan = plan, + }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return new BatchItemResult + { + Workspace = workspace, + Succeeded = false, + Error = ex.Message, + }; + } + } + + private static int Depth(string root, string path) + { + var relative = Path.GetRelativePath(root, path); + return relative.Count(c => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar); + } + + private static bool IsInExcludedDirectory( + string root, + string path, + System.Text.RegularExpressions.Regex? excludeRegex + ) => + ProjectDiscoveryService.IsExcluded( + root, + path, + new DiscoveryOptions { RootDirectory = root }, + excludeRegex + ); +} diff --git a/CentralConfigGenerator.Core/Workspace/DependencyTreeService.cs b/CentralConfigGenerator.Core/Workspace/DependencyTreeService.cs new file mode 100644 index 0000000..c33953d --- /dev/null +++ b/CentralConfigGenerator.Core/Workspace/DependencyTreeService.cs @@ -0,0 +1,118 @@ +using System.Text; +using CentralConfigGenerator.Core.NuGet; + +namespace CentralConfigGenerator.Core.Workspace; + +/// Renders the resolved dependency graph as an ASCII tree. +public sealed class DependencyTreeService(IPackageGraphService graphService) +{ + public async Task RenderAsync( + string target, + string workingDirectory, + bool includeTransitive, + CancellationToken cancellationToken = default + ) + { + var graph = await graphService.LoadAsync( + target, + workingDirectory, + includeTransitive, + cancellationToken + ); + + if (!graph.IsAvailable) + { + return $"Dependency graph unavailable: {graph.Error}"; + } + + return Render(graph, includeTransitive); + } + + internal static string Render(PackageGraph graph, bool includeTransitive) + { + var builder = new StringBuilder(); + + var byProject = graph + .Packages.GroupBy(p => p.ProjectPath, StringComparer.OrdinalIgnoreCase) + .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase) + .ToList(); + + for (var projectIndex = 0; projectIndex < byProject.Count; projectIndex++) + { + var project = byProject[projectIndex]; + builder.Append(Path.GetFileNameWithoutExtension(project.Key)).Append('\n'); + + var frameworks = project + .GroupBy(p => p.TargetFramework, StringComparer.OrdinalIgnoreCase) + .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase) + .ToList(); + + for (var frameworkIndex = 0; frameworkIndex < frameworks.Count; frameworkIndex++) + { + var framework = frameworks[frameworkIndex]; + var isLastFramework = frameworkIndex == frameworks.Count - 1; + var frameworkPrefix = isLastFramework ? "└── " : "├── "; + var childIndent = isLastFramework ? " " : "│ "; + + builder.Append(frameworkPrefix).Append(framework.Key).Append('\n'); + + var direct = framework + .Where(p => !p.IsTransitive) + .OrderBy(p => p.PackageId, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var transitive = includeTransitive + ? framework + .Where(p => p.IsTransitive) + .OrderBy(p => p.PackageId, StringComparer.OrdinalIgnoreCase) + .ToList() + : []; + + var total = direct.Count + transitive.Count; + var written = 0; + + foreach (var package in direct) + { + written++; + var last = written == total; + builder + .Append(childIndent) + .Append(last ? "└── " : "├── ") + .Append(package.PackageId) + .Append(' ') + .Append(package.ResolvedVersion); + + if ( + package.RequestedVersion is { } requested + && !string.Equals(requested, package.ResolvedVersion, StringComparison.OrdinalIgnoreCase) + ) + { + builder.Append(" (requested ").Append(requested).Append(')'); + } + + builder.Append('\n'); + } + + foreach (var package in transitive) + { + written++; + var last = written == total; + builder + .Append(childIndent) + .Append(last ? "└── " : "├── ") + .Append(package.PackageId) + .Append(' ') + .Append(package.ResolvedVersion) + .Append(" (transitive)\n"); + } + } + + if (projectIndex < byProject.Count - 1) + { + builder.Append('\n'); + } + } + + return builder.ToString(); + } +} diff --git a/CentralConfigGenerator.Core/Workspace/DoctorService.cs b/CentralConfigGenerator.Core/Workspace/DoctorService.cs new file mode 100644 index 0000000..05a2edb --- /dev/null +++ b/CentralConfigGenerator.Core/Workspace/DoctorService.cs @@ -0,0 +1,411 @@ +using CentralConfigGenerator.Core.Configuration; +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.NuGet; +using CentralConfigGenerator.Core.Services; +using CentralConfigGenerator.Core.Xml; + +namespace CentralConfigGenerator.Core.Workspace; + +public enum CheckStatus +{ + Pass, + Warn, + Fail, + Skipped, +} + +public sealed record DiagnosticCheck +{ + public required string Name { get; init; } + + public required CheckStatus Status { get; init; } + + public required string Detail { get; init; } + + public string? Remedy { get; init; } +} + +public sealed record DoctorReport +{ + public required IReadOnlyList Checks { get; init; } + + public bool HasFailures => Checks.Any(c => c.Status == CheckStatus.Fail); + + public bool HasWarnings => Checks.Any(c => c.Status == CheckStatus.Warn); +} + +/// +/// Environment diagnostics. Answers "why did that not work?" before the user has to ask, by +/// checking the SDK, the workspace layout and feed reachability in one pass. +/// +public sealed class DoctorService( + IFileSystem fileSystem, + IDotNetCliService cli, + IProjectDiscoveryService discovery, + INuGetFeedService feedService, + ConfigurationLoader configurationLoader +) +{ + public async Task RunAsync( + string rootDirectory, + CancellationToken cancellationToken = default + ) + { + var checks = new List(); + var root = Path.GetFullPath(rootDirectory); + + checks.Add(await CheckSdkAsync(root, cancellationToken)); + checks.Add(CheckGlobalJson(root)); + checks.Add(CheckSolutions(root)); + + var projects = await discovery.DiscoverAsync( + new DiscoveryOptions { RootDirectory = root }, + cancellationToken + ); + + checks.Add( + new DiagnosticCheck + { + Name = "Projects", + Status = projects.Count > 0 ? CheckStatus.Pass : CheckStatus.Fail, + Detail = projects.Count > 0 + ? $"{projects.Count} project file(s) discovered." + : "No .csproj/.fsproj/.vbproj found under this directory.", + Remedy = projects.Count > 0 ? null : "Run from the repository root, or pass --directory.", + } + ); + + checks.Add(await CheckCentralPackageManagementAsync(root, cancellationToken)); + checks.Add(CheckBuildProps(root)); + checks.Add(await CheckFeedsAsync(cancellationToken)); + checks.Add(await CheckConfigurationAsync(root, cancellationToken)); + checks.Add(CheckBackups(root)); + checks.Add(CheckGitIgnore(root)); + + return new DoctorReport { Checks = checks }; + } + + private async Task CheckSdkAsync( + string root, + CancellationToken cancellationToken + ) + { + if (!cli.IsAvailable) + { + return new DiagnosticCheck + { + Name = ".NET SDK", + Status = CheckStatus.Fail, + Detail = "The dotnet CLI was not found on PATH.", + Remedy = "Install the .NET SDK 8.0 or later from https://dot.net.", + }; + } + + var result = await cli.RunAsync( + "--version", + root, + TimeSpan.FromSeconds(30), + cancellationToken + ); + + var version = result.StandardOutput.Trim(); + + if (!result.Succeeded) + { + return new DiagnosticCheck + { + Name = ".NET SDK", + Status = CheckStatus.Fail, + Detail = result.CombinedOutput.Trim(), + Remedy = "Check global.json — it may pin an SDK version that is not installed.", + }; + } + + var major = ParseMajor(version); + + return new DiagnosticCheck + { + Name = ".NET SDK", + Status = major >= 8 ? CheckStatus.Pass : CheckStatus.Warn, + Detail = $"dotnet {version}", + Remedy = major >= 8 + ? null + : "Central package management needs SDK 6.0+; transitive pinning needs 8.0+.", + }; + } + + private DiagnosticCheck CheckGlobalJson(string root) + { + var path = Path.Combine(root, "global.json"); + + return fileSystem.FileExists(path) + ? new DiagnosticCheck + { + Name = "global.json", + Status = CheckStatus.Pass, + Detail = "Present — the SDK version is pinned for this repository.", + } + : new DiagnosticCheck + { + Name = "global.json", + Status = CheckStatus.Warn, + Detail = "Not present.", + Remedy = "Add a global.json so every machine builds with the same SDK.", + }; + } + + private DiagnosticCheck CheckSolutions(string root) + { + var solutions = fileSystem + .EnumerateFiles(root, "*", recursive: false) + .Where(SolutionReader.IsSolution) + .ToList(); + + return solutions.Count switch + { + 0 => new DiagnosticCheck + { + Name = "Solution", + Status = CheckStatus.Warn, + Detail = "No solution file in this directory; the scan will walk the whole tree.", + Remedy = "Pass --solution to scope the run precisely.", + }, + 1 => new DiagnosticCheck + { + Name = "Solution", + Status = CheckStatus.Pass, + Detail = Path.GetFileName(solutions[0]), + }, + _ => new DiagnosticCheck + { + Name = "Solution", + Status = CheckStatus.Warn, + Detail = $"{solutions.Count} solutions found: {string.Join(", ", solutions.Select(Path.GetFileName))}.", + Remedy = "Pass --solution so the tool knows which one you mean.", + }, + }; + } + + private async Task CheckCentralPackageManagementAsync( + string root, + CancellationToken cancellationToken + ) + { + var path = Path.Combine(root, "Directory.Packages.props"); + + if (!fileSystem.FileExists(path)) + { + return new DiagnosticCheck + { + Name = "Central package management", + Status = CheckStatus.Warn, + Detail = "Directory.Packages.props not found.", + Remedy = "Run 'central-config migrate' to create it.", + }; + } + + var file = await discovery.LoadAsync(path, cancellationToken); + + if (file is null) + { + return new DiagnosticCheck + { + Name = "Central package management", + Status = CheckStatus.Fail, + Detail = "Directory.Packages.props exists but could not be read.", + Remedy = "Check file permissions.", + }; + } + + if (!MsBuildXml.TryParse(file.Content, out var document, out var error)) + { + return new DiagnosticCheck + { + Name = "Central package management", + Status = CheckStatus.Fail, + Detail = $"Directory.Packages.props could not be parsed: {error}", + Remedy = "Fix the XML, or restore it from .centralconfig-backups.", + }; + } + + var enabled = MsBuildXml + .Descendants(document, "ManagePackageVersionsCentrally") + .Any(e => string.Equals(e.Value.Trim(), "true", StringComparison.OrdinalIgnoreCase)); + + var count = PackageReferenceReader.ReadPackageVersions(document).Count; + + return new DiagnosticCheck + { + Name = "Central package management", + Status = enabled ? CheckStatus.Pass : CheckStatus.Warn, + Detail = enabled + ? $"Enabled with {count} pinned package(s)." + : $"{count} entries present but ManagePackageVersionsCentrally is not true.", + Remedy = enabled + ? null + : "Set true.", + }; + } + + private DiagnosticCheck CheckBuildProps(string root) + { + var path = Path.Combine(root, "Directory.Build.props"); + + return fileSystem.FileExists(path) + ? new DiagnosticCheck + { + Name = "Directory.Build.props", + Status = CheckStatus.Pass, + Detail = "Present — shared properties are centralised.", + } + : new DiagnosticCheck + { + Name = "Directory.Build.props", + Status = CheckStatus.Warn, + Detail = "Not present.", + Remedy = "Run 'central-config build' to hoist shared properties.", + }; + } + + private async Task CheckFeedsAsync(CancellationToken cancellationToken) + { + var sources = feedService.Sources; + + if (sources.Count == 0) + { + return new DiagnosticCheck + { + Name = "NuGet feeds", + Status = CheckStatus.Fail, + Detail = "No enabled package sources were found.", + Remedy = "Check your nuget.config.", + }; + } + + // A cheap round-trip against a package that exists on every public mirror. + var probe = await feedService.GetMetadataAsync("Newtonsoft.Json", cancellationToken); + + return probe.Found + ? new DiagnosticCheck + { + Name = "NuGet feeds", + Status = CheckStatus.Pass, + Detail = $"{sources.Count} source(s) configured and reachable.", + } + : new DiagnosticCheck + { + Name = "NuGet feeds", + Status = CheckStatus.Warn, + Detail = probe.Error ?? "The probe package could not be resolved.", + Remedy = "Online checks (--audit, --outdated) will be skipped or incomplete.", + }; + } + + private async Task CheckConfigurationAsync( + string root, + CancellationToken cancellationToken + ) + { + var (settings, path, error) = await configurationLoader.LoadAsync(root, cancellationToken); + + if (error is not null) + { + return new DiagnosticCheck + { + Name = "Configuration", + Status = CheckStatus.Fail, + Detail = $"{path} is invalid: {error}", + Remedy = "Fix the JSON or delete the file.", + }; + } + + return settings is null + ? new DiagnosticCheck + { + Name = "Configuration", + Status = CheckStatus.Skipped, + Detail = $"No {ConfigurationLoader.FileName} found; command-line defaults apply.", + Remedy = "Run 'central-config init' to scaffold one.", + } + : new DiagnosticCheck + { + Name = "Configuration", + Status = CheckStatus.Pass, + Detail = $"Loaded from {path}.", + }; + } + + private DiagnosticCheck CheckBackups(string root) + { + var path = Path.Combine(root, BackupService.DefaultBackupFolderName); + + if (!fileSystem.DirectoryExists(path)) + { + return new DiagnosticCheck + { + Name = "Backups", + Status = CheckStatus.Skipped, + Detail = "No backups yet.", + }; + } + + var count = fileSystem.EnumerateDirectories(path).Count; + + return new DiagnosticCheck + { + Name = "Backups", + Status = count > 10 ? CheckStatus.Warn : CheckStatus.Pass, + Detail = $"{count} backup set(s) stored.", + Remedy = count > 10 ? "Run 'central-config backups prune' to trim old sets." : null, + }; + } + + private DiagnosticCheck CheckGitIgnore(string root) + { + var gitDirectory = Path.Combine(root, ".git"); + if (!fileSystem.DirectoryExists(gitDirectory)) + { + return new DiagnosticCheck + { + Name = ".gitignore", + Status = CheckStatus.Skipped, + Detail = "Not a git repository.", + }; + } + + var path = Path.Combine(root, ".gitignore"); + if (!fileSystem.FileExists(path)) + { + return new DiagnosticCheck + { + Name = ".gitignore", + Status = CheckStatus.Warn, + Detail = "No .gitignore in the repository root.", + Remedy = "Pass --add-gitignore so backups are not committed.", + }; + } + + var content = fileSystem.ReadAllTextAsync(path).GetAwaiter().GetResult(); + var ignored = content.Contains( + BackupService.DefaultBackupFolderName, + StringComparison.OrdinalIgnoreCase + ); + + return new DiagnosticCheck + { + Name = ".gitignore", + Status = ignored ? CheckStatus.Pass : CheckStatus.Warn, + Detail = ignored + ? "Backup directory is ignored." + : "Backup directory is not ignored.", + Remedy = ignored ? null : "Pass --add-gitignore on the next run.", + }; + } + + private static int ParseMajor(string version) + { + var head = version.Split('.').FirstOrDefault(); + return int.TryParse(head, out var major) ? major : 0; + } +} diff --git a/CentralConfigGenerator.Core/Workspace/PackageUpdateService.cs b/CentralConfigGenerator.Core/Workspace/PackageUpdateService.cs new file mode 100644 index 0000000..7581174 --- /dev/null +++ b/CentralConfigGenerator.Core/Workspace/PackageUpdateService.cs @@ -0,0 +1,508 @@ +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.Generators; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.NuGet; +using CentralConfigGenerator.Core.Services; +using CentralConfigGenerator.Core.Services.Abstractions; +using NuGet.Versioning; + +namespace CentralConfigGenerator.Core.Workspace; + +public sealed record PackageUpdateOptions +{ + public string RootDirectory { get; init; } = Directory.GetCurrentDirectory(); + + public string? SolutionPath { get; init; } + + public string? PackagesPropsPath { get; init; } + + public bool DryRun { get; init; } + + public bool IncludePrerelease { get; init; } + + /// Keep the largest subset of updates that still passes tests instead of reverting all. + public bool Bisect { get; init; } + + /// Upper bound on restore + test cycles spent bisecting. + public int BisectBudget { get; init; } = 16; + + public string? TestFilter { get; init; } + + /// Restrict the run to these package ids. + public IReadOnlyCollection Only { get; init; } = []; + + public bool CreateBackup { get; init; } = true; + + public string? BackupDirectory { get; init; } + + /// Skip the test run entirely and just write the updates. + public bool SkipTests { get; init; } + + public int MaxParallelism { get; init; } = 8; +} + +public sealed record PackageUpdateCandidate +{ + public required string PackageId { get; init; } + + public required string CurrentVersion { get; init; } + + public required string NewVersion { get; init; } + + public bool IsMajorUpgrade { get; init; } +} + +public sealed record PackageUpdateResult +{ + public required IReadOnlyList Candidates { get; init; } + + public required IReadOnlyList Applied { get; init; } + + public required IReadOnlyList HeldBack { get; init; } + + public bool TestsRun { get; init; } + + public bool TestsPassed { get; init; } + + public int BisectCycles { get; init; } + + public string? Message { get; init; } + + public BackupSet? Backup { get; init; } + + public bool RolledBack { get; init; } +} + +public interface IPackageUpdateService +{ + Task UpdateAsync( + PackageUpdateOptions options, + IProgress? progress = null, + CancellationToken cancellationToken = default + ); +} + +/// +/// Updates central package versions, runs the test suite, and rolls back on failure. With +/// --bisect it keeps the largest subset of updates that still passes instead of reverting +/// everything, and names the packages it held back. +/// +public sealed class PackageUpdateService( + IProjectDiscoveryService discovery, + IFileSystem fileSystem, + INuGetFeedService feedService, + IDotNetCliService cli, + IBackupService backupService +) : IPackageUpdateService +{ + public async Task UpdateAsync( + PackageUpdateOptions options, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + var propsPath = options.PackagesPropsPath + ?? Path.Combine(options.RootDirectory, "Directory.Packages.props"); + + if (!fileSystem.FileExists(propsPath)) + { + return Empty("No Directory.Packages.props found. Run the migration first."); + } + + var propsFile = await discovery.LoadAsync(propsPath, cancellationToken); + if (propsFile is null) + { + return Empty($"Could not read {propsPath}."); + } + + var original = propsFile.Content; + var currentVersions = ReadVersions(original); + + if (currentVersions.Count == 0) + { + return Empty("Directory.Packages.props declares no PackageVersion entries."); + } + + progress?.Report("Checking the feed for newer versions"); + + var only = options.Only.ToHashSet(StringComparer.OrdinalIgnoreCase); + var ids = currentVersions + .Keys.Where(id => only.Count == 0 || only.Contains(id)) + .ToList(); + + var metadata = await feedService.GetMetadataAsync( + ids, + options.MaxParallelism, + null, + cancellationToken + ); + + var candidates = BuildCandidates(currentVersions, metadata, options.IncludePrerelease); + + if (candidates.Count == 0) + { + return new PackageUpdateResult + { + Candidates = [], + Applied = [], + HeldBack = [], + Message = "Everything is already up to date.", + }; + } + + if (options.DryRun) + { + return new PackageUpdateResult + { + Candidates = candidates, + Applied = [], + HeldBack = [], + Message = $"{candidates.Count} update(s) available (dry run).", + }; + } + + var backup = options.CreateBackup + ? await backupService.CreateAsync( + options.RootDirectory, + options.BackupDirectory ?? options.RootDirectory, + "update", + [propsPath], + cancellationToken + ) + : null; + + var target = options.SolutionPath ?? options.RootDirectory; + + if (options.SkipTests || !cli.IsAvailable) + { + await WriteAsync(propsFile, original, candidates, cancellationToken); + + return new PackageUpdateResult + { + Candidates = candidates, + Applied = candidates, + HeldBack = [], + Backup = backup, + Message = options.SkipTests + ? "Applied without running tests." + : "dotnet CLI unavailable; applied without running tests.", + }; + } + + var cycles = 0; + + progress?.Report($"Applying {candidates.Count} update(s) and running tests"); + + if (await TryAsync(propsFile, original, candidates, options, target, cancellationToken)) + { + cycles++; + return new PackageUpdateResult + { + Candidates = candidates, + Applied = candidates, + HeldBack = [], + TestsRun = true, + TestsPassed = true, + BisectCycles = cycles, + Backup = backup, + Message = $"All {candidates.Count} update(s) applied; tests passed.", + }; + } + + cycles++; + + if (!options.Bisect) + { + await RestoreAsync(propsFile, original, cancellationToken); + + return new PackageUpdateResult + { + Candidates = candidates, + Applied = [], + HeldBack = candidates, + TestsRun = true, + TestsPassed = false, + BisectCycles = cycles, + Backup = backup, + RolledBack = true, + Message = "Tests failed; all updates were rolled back. Re-run with --bisect to keep the green subset.", + }; + } + + progress?.Report("Tests failed; bisecting for the largest green subset"); + + var budget = Math.Max(2, options.BisectBudget); + var green = await BisectAsync( + propsFile, + original, + candidates, + options, + target, + budget, + cycles, + progress, + cancellationToken + ); + + cycles = green.Cycles; + + if (green.Subset.Count == 0) + { + await RestoreAsync(propsFile, original, cancellationToken); + + return new PackageUpdateResult + { + Candidates = candidates, + Applied = [], + HeldBack = candidates, + TestsRun = true, + TestsPassed = false, + BisectCycles = cycles, + Backup = backup, + RolledBack = true, + Message = "No subset of the updates passed; everything was rolled back.", + }; + } + + await WriteAsync(propsFile, original, green.Subset, cancellationToken); + + var heldBack = candidates.Except(green.Subset).ToList(); + + return new PackageUpdateResult + { + Candidates = candidates, + Applied = green.Subset, + HeldBack = heldBack, + TestsRun = true, + TestsPassed = true, + BisectCycles = cycles, + Backup = backup, + Message = + $"Kept {green.Subset.Count} of {candidates.Count} update(s). Held back: " + + string.Join(", ", heldBack.Select(c => c.PackageId)), + }; + } + + /// + /// Divide and conquer: if a set fails, split it, keep whichever halves pass, then confirm the + /// union. Each restore+test cycle is charged against the budget. + /// + private async Task<(List Subset, int Cycles)> BisectAsync( + ProjectFile propsFile, + string original, + IReadOnlyList candidates, + PackageUpdateOptions options, + string target, + int budget, + int cycles, + IProgress? progress, + CancellationToken cancellationToken + ) + { + if (candidates.Count == 0 || cycles >= budget) + { + return ([], cycles); + } + + if (candidates.Count == 1) + { + progress?.Report($"Testing {candidates[0].PackageId} on its own"); + cycles++; + + var ok = await TryAsync( + propsFile, + original, + candidates, + options, + target, + cancellationToken + ); + + return (ok ? candidates.ToList() : [], cycles); + } + + var middle = candidates.Count / 2; + var left = candidates.Take(middle).ToList(); + var right = candidates.Skip(middle).ToList(); + + var leftResult = await TestSubsetAsync(left); + var rightResult = await TestSubsetAsync(right); + + List best; + + if (leftResult.Green && rightResult.Green) + { + // Both halves pass alone; confirm they also pass together. + if (cycles < budget) + { + cycles++; + var combined = left.Concat(right).ToList(); + if (await TryAsync(propsFile, original, combined, options, target, cancellationToken)) + { + return (combined, cycles); + } + } + + best = left.Count >= right.Count ? leftResult.Subset : rightResult.Subset; + } + else + { + best = leftResult.Subset.Count >= rightResult.Subset.Count + ? leftResult.Subset + : rightResult.Subset; + } + + return (best, cycles); + + async Task<(bool Green, List Subset)> TestSubsetAsync( + List subset + ) + { + if (subset.Count == 0 || cycles >= budget) + { + return (false, []); + } + + progress?.Report($"Testing a subset of {subset.Count} update(s)"); + cycles++; + + if (await TryAsync(propsFile, original, subset, options, target, cancellationToken)) + { + return (true, subset); + } + + var (nested, nestedCycles) = await BisectAsync( + propsFile, + original, + subset, + options, + target, + budget, + cycles, + progress, + cancellationToken + ); + + cycles = nestedCycles; + return (false, nested); + } + } + + private async Task TryAsync( + ProjectFile propsFile, + string original, + IReadOnlyList subset, + PackageUpdateOptions options, + string target, + CancellationToken cancellationToken + ) + { + await WriteAsync(propsFile, original, subset, cancellationToken); + + var restore = await cli.RestoreAsync(target, options.RootDirectory, cancellationToken); + if (!restore.Succeeded) + { + return false; + } + + var test = await cli.TestAsync( + target, + options.RootDirectory, + options.TestFilter, + cancellationToken + ); + + return test.Succeeded; + } + + private async Task WriteAsync( + ProjectFile propsFile, + string original, + IReadOnlyList subset, + CancellationToken cancellationToken + ) + { + var content = original; + + foreach (var candidate in subset) + { + content = + PackagesPropsEditor.SetVersion(content, candidate.PackageId, candidate.NewVersion) + ?? content; + } + + await fileSystem.WriteAllBytesAsync( + propsFile.Path, + EncodingDetector.Encode(content, propsFile.Format), + cancellationToken + ); + } + + private Task RestoreAsync( + ProjectFile propsFile, + string original, + CancellationToken cancellationToken + ) => + fileSystem.WriteAllBytesAsync( + propsFile.Path, + EncodingDetector.Encode(original, propsFile.Format), + cancellationToken + ); + + private static List BuildCandidates( + IReadOnlyDictionary current, + IReadOnlyDictionary metadata, + bool includePrerelease + ) + { + var candidates = new List(); + + foreach (var (packageId, version) in current.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase)) + { + if ( + !metadata.TryGetValue(packageId, out var snapshot) + || !snapshot.Found + || !NuGetVersion.TryParse(version, out var currentVersion) + ) + { + continue; + } + + var latest = includePrerelease + ? snapshot.LatestIncludingPrerelease + : snapshot.LatestStable; + + if (latest is null || latest <= currentVersion) + { + continue; + } + + candidates.Add( + new PackageUpdateCandidate + { + PackageId = packageId, + CurrentVersion = version, + NewVersion = latest.ToNormalizedString(), + IsMajorUpgrade = latest.Major > currentVersion.Major, + } + ); + } + + return candidates; + } + + private static IReadOnlyDictionary ReadVersions(string content) => + Xml.MsBuildXml.TryParse(content, out var document, out _) + ? Xml.PackageReferenceReader.ReadPackageVersions(document) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + + private static PackageUpdateResult Empty(string message) => + new() + { + Candidates = [], + Applied = [], + HeldBack = [], + Message = message, + }; +} diff --git a/CentralConfigGenerator.Core/Workspace/StatusService.cs b/CentralConfigGenerator.Core/Workspace/StatusService.cs new file mode 100644 index 0000000..e6ef3f6 --- /dev/null +++ b/CentralConfigGenerator.Core/Workspace/StatusService.cs @@ -0,0 +1,105 @@ +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Services; + +namespace CentralConfigGenerator.Core.Workspace; + +/// One-shot snapshot of a workspace: what is there and how healthy it is. +public sealed record WorkspaceStatus +{ + public required string RootDirectory { get; init; } + + public required int ProjectCount { get; init; } + + public required int PackageCount { get; init; } + + public required bool CentralPackageManagementEnabled { get; init; } + + public required bool TransitivePinningEnabled { get; init; } + + public bool HasBuildProps { get; init; } + + public bool HasConfigFile { get; init; } + + public int BackupCount { get; init; } + + public IReadOnlyList Solutions { get; init; } = []; + + public IReadOnlyDictionary ProjectKinds { get; init; } = + new Dictionary(); + + public IReadOnlyList TargetFrameworks { get; init; } = []; + + public required AnalysisReport Analysis { get; init; } + + /// The single next action that would most improve this workspace. + public string NextStep => + !CentralPackageManagementEnabled + ? "Run 'central-config migrate --dry-run --diff' to preview central package management." + : Analysis.CountOf(Severity.Critical) > 0 + ? "Run 'central-config analyze --audit' — there are critical findings." + : Analysis.Findings.Count(f => f.IsAutoFixable) > 0 + ? "Run 'central-config analyze --fix' to apply the auto-fixable findings." + : !HasBuildProps + ? "Run 'central-config build' to centralise shared MSBuild properties." + : "Nothing urgent. Consider 'central-config update --bisect' to move versions forward."; +} + +/// Builds the status dashboard from a fast, offline-only analysis pass. +public sealed class StatusService( + IAnalysisEngine analysisEngine, + IFileSystem fileSystem, + Configuration.ConfigurationLoader configurationLoader +) +{ + public async Task GetAsync( + string rootDirectory, + AnalysisOptions? analysisOptions = null, + CancellationToken cancellationToken = default + ) + { + var root = Path.GetFullPath(rootDirectory); + var options = analysisOptions ?? new AnalysisOptions { RootDirectory = root }; + + var context = await analysisEngine.BuildContextAsync(options, null, cancellationToken); + var report = await analysisEngine.AnalyzeAsync(options, null, cancellationToken); + + var backupRoot = Path.Combine(root, BackupService.DefaultBackupFolderName); + + return new WorkspaceStatus + { + RootDirectory = root, + ProjectCount = context.Projects.Count, + PackageCount = context + .References.Select(r => r.PackageId) + .Concat(context.CentralVersions.Keys) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(), + CentralPackageManagementEnabled = context.CentralPackageManagementEnabled, + TransitivePinningEnabled = context.TransitivePinningEnabled, + HasBuildProps = fileSystem.FileExists(Path.Combine(root, "Directory.Build.props")), + HasConfigFile = configurationLoader.Find(root) is not null, + BackupCount = fileSystem.DirectoryExists(backupRoot) + ? fileSystem.EnumerateDirectories(backupRoot).Count + : 0, + Solutions = fileSystem + .EnumerateFiles(root, "*", recursive: false) + .Where(SolutionReader.IsSolution) + .Select(Path.GetFileName) + .Where(n => n is not null) + .Select(n => n!) + .ToList(), + ProjectKinds = context + .Projects.GroupBy(p => p.ResolvedKind) + .ToDictionary(g => g.Key, g => g.Count()), + TargetFrameworks = context + .TargetFrameworks.SelectMany(pair => pair.Value) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(t => t, StringComparer.OrdinalIgnoreCase) + .ToList(), + Analysis = report, + }; + } +} diff --git a/CentralConfigGenerator.Core/Workspace/VerificationService.cs b/CentralConfigGenerator.Core/Workspace/VerificationService.cs new file mode 100644 index 0000000..04193fd --- /dev/null +++ b/CentralConfigGenerator.Core/Workspace/VerificationService.cs @@ -0,0 +1,167 @@ +using CentralConfigGenerator.Core.NuGet; +using CentralConfigGenerator.Core.Services; + +namespace CentralConfigGenerator.Core.Workspace; + +/// One package whose resolved version changed between two graph snapshots. +public sealed record GraphDrift +{ + public required string Key { get; init; } + + public required string PackageId { get; init; } + + public string? Before { get; init; } + + public string? After { get; init; } + + public bool IsAddition => Before is null; + + public bool IsRemoval => After is null; +} + +public sealed record VerificationResult +{ + public required bool GraphAvailable { get; init; } + + public required IReadOnlyList Drift { get; init; } + + public string? Error { get; init; } + + public int BeforeCount { get; init; } + + public int AfterCount { get; init; } + + public bool IsClean => Drift.Count == 0; +} + +public interface IVerificationService +{ + Task SnapshotAsync( + string target, + string workingDirectory, + CancellationToken cancellationToken = default + ); + + VerificationResult Compare(PackageGraph before, PackageGraph after); +} + +/// +/// Proves a migration did not change what NuGet actually resolves, by diffing the full package +/// graph before and after. This is the difference between "the files look right" and "the build +/// produces the same binaries". +/// +public sealed class VerificationService( + IPackageGraphService graphService, + IDotNetCliService cli +) : IVerificationService +{ + public async Task SnapshotAsync( + string target, + string workingDirectory, + CancellationToken cancellationToken = default + ) + { + // dotnet list package needs an up-to-date assets file to report anything useful. + var restore = await cli.RestoreAsync(target, workingDirectory, cancellationToken); + if (!restore.Succeeded) + { + return PackageGraph.Empty with + { + Error = $"restore failed: {FirstError(restore.CombinedOutput)}", + }; + } + + return await graphService.LoadAsync( + target, + workingDirectory, + includeTransitive: true, + cancellationToken + ); + } + + public VerificationResult Compare(PackageGraph before, PackageGraph after) + { + if (!before.IsAvailable || !after.IsAvailable) + { + return new VerificationResult + { + GraphAvailable = false, + Drift = [], + Error = before.Error ?? after.Error, + BeforeCount = before.Packages.Count, + AfterCount = after.Packages.Count, + }; + } + + var beforeSignature = before.Signature(); + var afterSignature = after.Signature(); + var drift = new List(); + + foreach (var (key, version) in beforeSignature) + { + if (!afterSignature.TryGetValue(key, out var newVersion)) + { + drift.Add( + new GraphDrift + { + Key = key, + PackageId = PackageIdFromKey(key), + Before = version, + After = null, + } + ); + continue; + } + + if (!string.Equals(version, newVersion, StringComparison.OrdinalIgnoreCase)) + { + drift.Add( + new GraphDrift + { + Key = key, + PackageId = PackageIdFromKey(key), + Before = version, + After = newVersion, + } + ); + } + } + + foreach (var (key, version) in afterSignature) + { + if (!beforeSignature.ContainsKey(key)) + { + drift.Add( + new GraphDrift + { + Key = key, + PackageId = PackageIdFromKey(key), + Before = null, + After = version, + } + ); + } + } + + return new VerificationResult + { + GraphAvailable = true, + Drift = drift.OrderBy(d => d.PackageId, StringComparer.OrdinalIgnoreCase).ToList(), + BeforeCount = beforeSignature.Count, + AfterCount = afterSignature.Count, + }; + } + + private static string PackageIdFromKey(string key) + { + var index = key.LastIndexOf('|'); + return index < 0 ? key : key[(index + 1)..]; + } + + private static string FirstError(string output) => + output + .Split('\n') + .Select(l => l.Trim()) + .FirstOrDefault(l => l.Contains("error", StringComparison.OrdinalIgnoreCase)) + ?? "see build output"; +} diff --git a/CentralConfigGenerator.Core/Xml/MsBuildXml.cs b/CentralConfigGenerator.Core/Xml/MsBuildXml.cs new file mode 100644 index 0000000..7394884 --- /dev/null +++ b/CentralConfigGenerator.Core/Xml/MsBuildXml.cs @@ -0,0 +1,156 @@ +using System.Text; +using System.Xml; +using System.Xml.Linq; + +namespace CentralConfigGenerator.Core.Xml; + +/// +/// Helpers for editing MSBuild XML with as little collateral reformatting as possible. +/// +public static class MsBuildXml +{ + /// + /// Parses MSBuild XML keeping all whitespace so untouched regions round-trip byte-for-byte. + /// + public static XDocument Parse(string content) => + XDocument.Parse(content, LoadOptions.PreserveWhitespace); + + public static bool TryParse(string content, out XDocument document, out string? error) + { + try + { + document = Parse(content); + error = null; + return true; + } + catch (XmlException ex) + { + document = new XDocument(); + error = ex.Message; + return false; + } + } + + /// + /// Serialises a document, keeping the original XML declaration and indentation style. + /// + public static string ToString(XDocument document, bool preserveWhitespace = true) + { + var builder = new StringBuilder(); + var settings = new XmlWriterSettings + { + Indent = !preserveWhitespace, + IndentChars = " ", + OmitXmlDeclaration = document.Declaration is null, + Encoding = new UTF8Encoding(false), + NewLineHandling = NewLineHandling.None, + }; + + using (var writer = XmlWriter.Create(builder, settings)) + { + document.Save(writer); + } + + return builder.ToString(); + } + + /// + /// Removes an element along with the insignificant whitespace that preceded it, so the + /// surrounding file does not gain blank lines. + /// + public static void RemoveWithWhitespace(XElement element) + { + if (element.PreviousNode is XText { } text && text.Value.Trim().Length == 0) + { + text.Remove(); + } + + element.Remove(); + } + + /// + /// Removes an item group once it no longer has any element children. + /// + public static void RemoveIfEmpty(XElement? container) + { + if (container is null || container.Elements().Any()) + { + return; + } + + RemoveWithWhitespace(container); + } + + /// + /// Returns the indentation string used by the closest sibling, defaulting to two spaces + /// per nesting level. + /// + public static string GetIndent(XElement element) + { + var depth = 0; + for (var parent = element.Parent; parent is not null; parent = parent.Parent) + { + depth++; + } + + if (element.PreviousNode is XText { } text) + { + var value = text.Value; + var index = value.LastIndexOfAny(['\n', '\r']); + if (index >= 0) + { + return value[(index + 1)..]; + } + } + + return new string(' ', depth * 2); + } + + /// Gets an element's attribute value case-insensitively. + public static string? AttributeValue(XElement element, string name) + { + var attribute = element + .Attributes() + .FirstOrDefault(a => + string.Equals(a.Name.LocalName, name, StringComparison.OrdinalIgnoreCase) + ); + + return attribute?.Value; + } + + public static XAttribute? Attribute(XElement element, string name) => + element + .Attributes() + .FirstOrDefault(a => + string.Equals(a.Name.LocalName, name, StringComparison.OrdinalIgnoreCase) + ); + + /// Finds descendants by local name, ignoring any MSBuild XML namespace. + public static IEnumerable Descendants(XContainer container, string localName) => + container + .Descendants() + .Where(e => string.Equals(e.Name.LocalName, localName, StringComparison.OrdinalIgnoreCase)); + + public static IEnumerable Elements(XContainer container, string localName) => + container + .Elements() + .Where(e => string.Equals(e.Name.LocalName, localName, StringComparison.OrdinalIgnoreCase)); + + /// Returns the effective condition of an element, including inherited item-group conditions. + public static string? EffectiveCondition(XElement element) + { + var conditions = new List(); + + for (var current = element; current is not null; current = current.Parent) + { + var condition = AttributeValue(current, "Condition"); + if (!string.IsNullOrWhiteSpace(condition)) + { + conditions.Add(condition.Trim()); + } + } + + conditions.Reverse(); + return conditions.Count == 0 ? null : string.Join(" AND ", conditions); + } +} diff --git a/CentralConfigGenerator.Core/Xml/PackageReferenceReader.cs b/CentralConfigGenerator.Core/Xml/PackageReferenceReader.cs new file mode 100644 index 0000000..06091f2 --- /dev/null +++ b/CentralConfigGenerator.Core/Xml/PackageReferenceReader.cs @@ -0,0 +1,107 @@ +using System.Xml.Linq; +using CentralConfigGenerator.Core.Models; + +namespace CentralConfigGenerator.Core.Xml; + +/// +/// Extracts package references from project XML, covering attribute versions, child-element +/// versions, VersionOverride and GlobalPackageReference. +/// +public static class PackageReferenceReader +{ + public static IReadOnlyList Read(ProjectFile projectFile) + { + if (!MsBuildXml.TryParse(projectFile.Content, out var document, out _)) + { + return []; + } + + return Read(document, projectFile.Path); + } + + public static IReadOnlyList Read(XDocument document, string projectPath) + { + var results = new List(); + + foreach (var element in MsBuildXml.Descendants(document, "PackageReference")) + { + var info = ReadOne(element, projectPath, isGlobal: false); + if (info is not null) + { + results.Add(info); + } + } + + foreach (var element in MsBuildXml.Descendants(document, "GlobalPackageReference")) + { + var info = ReadOne(element, projectPath, isGlobal: true); + if (info is not null) + { + results.Add(info); + } + } + + return results; + } + + /// Reads PackageVersion entries from a Directory.Packages.props document. + public static IReadOnlyDictionary ReadPackageVersions(XDocument document) + { + var results = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var element in MsBuildXml.Descendants(document, "PackageVersion")) + { + var id = MsBuildXml.AttributeValue(element, "Include") + ?? MsBuildXml.AttributeValue(element, "Update"); + if (string.IsNullOrWhiteSpace(id)) + { + continue; + } + + var version = + MsBuildXml.AttributeValue(element, "Version") + ?? MsBuildXml.Elements(element, "Version").FirstOrDefault()?.Value; + + if (!string.IsNullOrWhiteSpace(version)) + { + results[id.Trim()] = version.Trim(); + } + } + + return results; + } + + private static PackageReferenceInfo? ReadOne(XElement element, string projectPath, bool isGlobal) + { + var id = + MsBuildXml.AttributeValue(element, "Include") + ?? MsBuildXml.AttributeValue(element, "Update"); + + if (string.IsNullOrWhiteSpace(id)) + { + return null; + } + + var versionAttribute = MsBuildXml.Attribute(element, "Version"); + var versionElement = MsBuildXml.Elements(element, "Version").FirstOrDefault(); + var overrideAttribute = MsBuildXml.Attribute(element, "VersionOverride"); + + var version = + versionAttribute?.Value ?? versionElement?.Value ?? overrideAttribute?.Value; + + var privateAssets = + MsBuildXml.AttributeValue(element, "PrivateAssets") + ?? MsBuildXml.Elements(element, "PrivateAssets").FirstOrDefault()?.Value; + + return new PackageReferenceInfo + { + PackageId = id.Trim(), + Version = string.IsNullOrWhiteSpace(version) ? null : version.Trim(), + ProjectPath = projectPath, + Condition = MsBuildXml.EffectiveCondition(element), + VersionIsElement = versionAttribute is null && versionElement is not null, + IsGlobal = isGlobal, + IsPrivateAssets = !string.IsNullOrWhiteSpace(privateAssets), + }; + } +} diff --git a/CentralConfigGenerator.Tests/CentralConfigGenerator.Tests.csproj b/CentralConfigGenerator.Tests/CentralConfigGenerator.Tests.csproj index 349e98f..a3444fe 100644 --- a/CentralConfigGenerator.Tests/CentralConfigGenerator.Tests.csproj +++ b/CentralConfigGenerator.Tests/CentralConfigGenerator.Tests.csproj @@ -1,5 +1,7 @@ + net9.0 + Major false true diff --git a/CentralConfigGenerator.Tests/Cli/CompletionsCommandTests.cs b/CentralConfigGenerator.Tests/Cli/CompletionsCommandTests.cs new file mode 100644 index 0000000..26eb20e --- /dev/null +++ b/CentralConfigGenerator.Tests/Cli/CompletionsCommandTests.cs @@ -0,0 +1,50 @@ +using CentralConfigGenerator.Cli.Commands; + +namespace CentralConfigGenerator.Tests.Cli; + +public class CompletionsCommandTests +{ + [Theory] + [InlineData("bash")] + [InlineData("zsh")] + [InlineData("fish")] + [InlineData("powershell")] + [InlineData("pwsh")] + [InlineData("BASH")] + public void ShouldEmitAScriptForEverySupportedShell(string shell) + { + var script = CompletionsCommand.Render(shell); + + script.ShouldNotBeNull(); + script.ShouldContain("central-config"); + script.ShouldContain("migrate"); + } + + [Theory] + [InlineData("tcsh")] + [InlineData("")] + public void ShouldReturnNothingForAnUnsupportedShell(string shell) => + CompletionsCommand.Render(shell).ShouldBeNull(); + + [Fact] + public void BashScriptShouldDefineACompletionFunction() => + Render("bash").ShouldContain("complete -F _central_config_completions"); + + [Fact] + public void ZshScriptShouldDeclareTheCompdef() => Render("zsh").ShouldContain("#compdef central-config"); + + [Fact] + public void PowerShellScriptShouldRegisterAnArgumentCompleter() => + Render("powershell").ShouldContain("Register-ArgumentCompleter"); + + [Theory] + [InlineData("bash")] + [InlineData("zsh")] + [InlineData("fish")] + [InlineData("powershell")] + public void EveryScriptShouldListTheAnalyzeCommand(string shell) => + Render(shell).ShouldContain("analyze"); + + private static string Render(string shell) => + CompletionsCommand.Render(shell).ShouldNotBeNull(); +} diff --git a/CentralConfigGenerator.Tests/Cli/EffectiveSettingsTests.cs b/CentralConfigGenerator.Tests/Cli/EffectiveSettingsTests.cs new file mode 100644 index 0000000..b2e961a --- /dev/null +++ b/CentralConfigGenerator.Tests/Cli/EffectiveSettingsTests.cs @@ -0,0 +1,140 @@ +using CentralConfigGenerator.Cli.Commands; +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Configuration; +using CentralConfigGenerator.Core.IO; +using CentralConfigGenerator.Core.Services; + +namespace CentralConfigGenerator.Tests.Cli; + +public class EffectiveSettingsTests : IDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), + "ccg-effective-" + Guid.NewGuid().ToString("N") + ); + + public EffectiveSettingsTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + + GC.SuppressFinalize(this); + } + + [Fact] + public async Task LoadAsync_ShouldPickUpTheConfigFile() + { + WriteConfig("""{ "conflictStrategy": "Lowest" }"""); + + var effective = await LoadAsync(); + + effective.File.ShouldNotBeNull(); + effective.Strategy(null).ShouldBe(VersionResolutionStrategy.Lowest); + } + + [Fact] + public async Task LoadAsync_ShouldSkipTheConfigFileWhenDisabled() + { + WriteConfig("""{ "conflictStrategy": "Lowest" }"""); + + var effective = await LoadAsync(noConfig: true); + + effective.File.ShouldBeNull(); + effective.Strategy(null).ShouldBe(VersionResolutionStrategy.Highest); + } + + [Fact] + public async Task Strategy_ShouldPreferTheCommandLine() + { + WriteConfig("""{ "conflictStrategy": "Lowest" }"""); + + var effective = await LoadAsync(); + + effective.Strategy("MostCommon").ShouldBe(VersionResolutionStrategy.MostCommon); + } + + [Fact] + public async Task Strategy_ShouldFallBackToHighestForUnknownValues() + { + var effective = await LoadAsync(); + + effective.Strategy("nonsense").ShouldBe(VersionResolutionStrategy.Highest); + } + + [Fact] + public async Task Bool_ShouldOrTheCommandLineWithTheFile() + { + WriteConfig("""{ "transitivePinning": true }"""); + + var effective = await LoadAsync(); + + effective.Bool(false, c => c.TransitivePinning).ShouldBeTrue(); + effective.Bool(true, c => c.IgnorePrerelease).ShouldBeTrue(); + } + + [Fact] + public async Task Text_ShouldPreferTheCommandLine() + { + WriteConfig("""{ "excludeDirs": "from-file" }"""); + + var effective = await LoadAsync(); + + effective.Text("from-cli", c => c.ExcludeDirs).ShouldBe("from-cli"); + effective.Text(null, c => c.ExcludeDirs).ShouldBe("from-file"); + } + + [Fact] + public async Task RuleOverrides_ShouldMergeFileAndCommandLine() + { + WriteConfig($$"""{ "rules": { "{{RuleIds.OutdatedPackage}}": "Critical" } }"""); + + var effective = await LoadAsync(); + + var overrides = effective.RuleOverrides([$"{RuleIds.FloatingVersion}=High"]); + + overrides[RuleIds.OutdatedPackage].ShouldBe(Severity.Critical); + overrides[RuleIds.FloatingVersion].ShouldBe(Severity.High); + } + + [Fact] + public async Task RuleOverrides_ShouldTreatNoneAsDisabled() + { + var effective = await LoadAsync(); + + var overrides = effective.RuleOverrides([$"{RuleIds.OutdatedPackage}=none"]); + + overrides.ShouldContainKey(RuleIds.OutdatedPackage); + overrides[RuleIds.OutdatedPackage].ShouldBeNull(); + } + + [Fact] + public async Task RuleOverrides_ShouldIgnoreUnknownRules() + { + var effective = await LoadAsync(); + + effective.RuleOverrides(["NotARule=High"]).ShouldBeEmpty(); + } + + [Fact] + public async Task RuleOverrides_ShouldIgnoreMalformedEntries() + { + var effective = await LoadAsync(); + + effective.RuleOverrides(["no-equals-sign"]).ShouldBeEmpty(); + } + + private void WriteConfig(string json) => + File.WriteAllText(Path.Combine(_root, ConfigurationLoader.FileName), json); + + private async Task LoadAsync(bool noConfig = false) + { + var effective = new EffectiveSettings(new ConfigurationLoader(new PhysicalFileSystem())); + await effective.LoadAsync(new MigrateSettings { Directory = _root, NoConfig = noConfig }); + return effective; + } +} diff --git a/CentralConfigGenerator.Tests/Cli/ServiceRegistrationTests.cs b/CentralConfigGenerator.Tests/Cli/ServiceRegistrationTests.cs new file mode 100644 index 0000000..6760508 --- /dev/null +++ b/CentralConfigGenerator.Tests/Cli/ServiceRegistrationTests.cs @@ -0,0 +1,63 @@ +using CentralConfigGenerator.Cli.Commands; +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Discovery; +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Workspace; +using Microsoft.Extensions.DependencyInjection; + +namespace CentralConfigGenerator.Tests.Cli; + +public class ServiceRegistrationTests +{ + [Theory] + [InlineData(typeof(MigrateCommand))] + [InlineData(typeof(BuildPropsCommand))] + [InlineData(typeof(AllCommand))] + [InlineData(typeof(RevertCommand))] + [InlineData(typeof(AnalyzeCommand))] + [InlineData(typeof(UpdateCommand))] + [InlineData(typeof(VerifyCommand))] + [InlineData(typeof(TreeCommand))] + [InlineData(typeof(StatusCommand))] + [InlineData(typeof(DoctorCommand))] + [InlineData(typeof(InitCommand))] + [InlineData(typeof(ExplainCommand))] + [InlineData(typeof(BatchCommand))] + [InlineData(typeof(BackupListCommand))] + [InlineData(typeof(BackupRestoreCommand))] + [InlineData(typeof(BackupPruneCommand))] + [InlineData(typeof(CompletionsCommand))] + public void EveryCommandShouldResolve(Type commandType) + { + using var provider = Program.ConfigureServices(); + + provider.GetService(commandType).ShouldNotBeNull(); + } + + [Theory] + [InlineData(typeof(IMigrationService))] + [InlineData(typeof(IAnalysisEngine))] + [InlineData(typeof(IAutoFixService))] + [InlineData(typeof(IProjectDiscoveryService))] + [InlineData(typeof(IVerificationService))] + [InlineData(typeof(IPackageUpdateService))] + [InlineData(typeof(DoctorService))] + [InlineData(typeof(StatusService))] + [InlineData(typeof(BatchService))] + public void EveryServiceShouldResolve(Type serviceType) + { + using var provider = Program.ConfigureServices(); + + provider.GetService(serviceType).ShouldNotBeNull(); + } + + [Fact] + public void LegacyCommandsShouldStillResolve() + { + using var provider = Program.ConfigureServices(); + + provider.GetService().ShouldNotBeNull(); + provider.GetService().ShouldNotBeNull(); + provider.GetService().ShouldNotBeNull(); + } +} diff --git a/CentralConfigGenerator.Tests/Cli/WorkspaceSettingsTests.cs b/CentralConfigGenerator.Tests/Cli/WorkspaceSettingsTests.cs new file mode 100644 index 0000000..fbd956b --- /dev/null +++ b/CentralConfigGenerator.Tests/Cli/WorkspaceSettingsTests.cs @@ -0,0 +1,58 @@ +using CentralConfigGenerator.Cli.Commands; + +namespace CentralConfigGenerator.Tests.Cli; + +public class WorkspaceSettingsTests +{ + private static readonly string Temp = Path.GetFullPath(Path.GetTempPath()) + .TrimEnd(Path.DirectorySeparatorChar); + + [Fact] + public void ResolvedDirectory_ShouldDefaultToTheCurrentDirectory() => + new MigrateSettings().ResolvedDirectory.ShouldBe( + Directory.GetCurrentDirectory().TrimEnd(Path.DirectorySeparatorChar) + ); + + [Fact] + public void ResolvedDirectory_ShouldFollowTheSolutionFolder() + { + var solution = Path.Combine(Path.GetTempPath(), "Some.sln"); + + new MigrateSettings { Solution = solution }.ResolvedDirectory.ShouldBe(Temp); + } + + [Fact] + public void ResolvedDirectory_ShouldPreferAnExplicitDirectory() + { + var settings = new MigrateSettings + { + Directory = Path.GetTempPath(), + Solution = Path.Combine("elsewhere", "Some.sln"), + }; + + settings.ResolvedDirectory.ShouldBe(Temp); + } + + [Fact] + public void Validate_ShouldRejectAMissingDirectory() + { + var settings = new MigrateSettings { Directory = Path.Combine(Path.GetTempPath(), "nope-" + Guid.NewGuid()) }; + + settings.Validate().Successful.ShouldBeFalse(); + } + + [Fact] + public void Validate_ShouldAcceptAnExistingDirectory() => + new MigrateSettings { Directory = Path.GetTempPath() }.Validate().Successful.ShouldBeTrue(); + + [Fact] + public void Validate_ShouldRejectAMissingSolution() + { + var settings = new MigrateSettings + { + Solution = Path.Combine(Path.GetTempPath(), "missing-" + Guid.NewGuid() + ".sln"), + }; + + settings.Validate().Successful.ShouldBeFalse(); + } +} diff --git a/CentralConfigGenerator/CentralConfigGenerator.csproj b/CentralConfigGenerator/CentralConfigGenerator.csproj index 88b1080..510bd3e 100644 --- a/CentralConfigGenerator/CentralConfigGenerator.csproj +++ b/CentralConfigGenerator/CentralConfigGenerator.csproj @@ -1,9 +1,9 @@ + net8.0;net9.0;net10.0 Exe true true - true CentralConfigGenerator true central-config @@ -13,13 +13,18 @@ README.md - A modern .NET tool for automatically generating centralized configuration files for .NET projects. - CentralConfig analyzes your solution structure and creates properly configured `Directory.Build.props` and `Directory.Packages.props` files to standardize settings across your projects. + Migrate .NET solutions to Central Package Management and keep them healthy. + Generates Directory.Packages.props and Directory.Build.props, verifies the resolved package graph is unchanged, + audits dependencies for vulnerabilities, deprecations, licence risk and version drift, applies auto-fixes, + and updates packages with test-backed rollback. Supports C#, F# and VB, dry-run diffs, backups and rollback, + SARIF/JSON/Markdown/CSV reports, and CI-friendly exit codes. - 1.1.1 + 2.0.0 + 2.0.0.0 + 2.0.0.0 Taras Kovalenko Copyright Taras Kovalenko - dotnet;msbuild;build;props;packages;centralized;configuration;directory-build-props;sdk;cli;tool;nuget;CPM;centralised + dotnet;msbuild;build;props;packages;centralized;centralised;configuration;directory-build-props;directory-packages-props;sdk;cli;tool;nuget;cpm;migration;audit;vulnerabilities;sarif;dependencies;devops CentralConfigGenerator MIT https://github.com/TarasKovalenko/CentralConfigGenerator @@ -28,6 +33,10 @@ false + + + + @@ -36,10 +45,10 @@ - + \ No newline at end of file diff --git a/CentralConfigGenerator/Cli/Commands/AllCommand.cs b/CentralConfigGenerator/Cli/Commands/AllCommand.cs new file mode 100644 index 0000000..854821a --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/AllCommand.cs @@ -0,0 +1,53 @@ +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Core.Models; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public sealed class AllSettings : MigrateSettings; + +/// Generates both Directory.Build.props and Directory.Packages.props in one pass. +public sealed class AllCommand(MigrateCommand migrate) : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, AllSettings settings) + { + // The migration already knows how to hoist properties; --unify-props is what "all" means. + var combined = settings; + + Ui.Quiet = settings.Quiet; + Ui.Verbose = settings.Verbose; + + var migrateSettings = new MigrateSettings + { + Directory = combined.Directory, + Solution = combined.Solution, + Project = combined.Project, + ExcludeDirs = combined.ExcludeDirs, + Quiet = combined.Quiet, + Verbose = combined.Verbose, + NoConfig = combined.NoConfig, + DryRun = combined.DryRun, + Diff = combined.Diff, + NoBackup = combined.NoBackup, + BackupDir = combined.BackupDir, + AddGitIgnore = combined.AddGitIgnore, + Force = combined.Force, + OutputDir = combined.OutputDir, + Merge = combined.Merge, + Overwrite = combined.Overwrite, + KeepAttributes = combined.KeepAttributes, + ConflictStrategy = combined.ConflictStrategy, + InteractiveConflicts = combined.InteractiveConflicts, + MinVersion = combined.MinVersion, + IgnorePrerelease = combined.IgnorePrerelease, + TransitivePinning = combined.TransitivePinning, + Encoding = combined.Encoding, + LineWrap = combined.LineWrap, + VersionComparison = combined.VersionComparison, + UnifyProps = true, + IncludeFSharp = combined.IncludeFSharp, + }; + + return await migrate.ExecuteAsync(context, migrateSettings); + } +} diff --git a/CentralConfigGenerator/Cli/Commands/AnalyzeCommand.cs b/CentralConfigGenerator/Cli/Commands/AnalyzeCommand.cs new file mode 100644 index 0000000..9b241ff --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/AnalyzeCommand.cs @@ -0,0 +1,290 @@ +using System.ComponentModel; +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Cli.Rendering; +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Reporting; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public sealed class AnalyzeSettings : WorkspaceSettings +{ + [CommandOption("-a|--audit")] + [Description("Check every package against published security advisories.")] + public bool Audit { get; init; } + + [CommandOption("--outdated")] + [Description("Report packages that are behind the feed.")] + public bool Outdated { get; init; } + + [CommandOption("--deprecated")] + [Description("Report packages the author marked deprecated.")] + public bool Deprecated { get; init; } + + [CommandOption("--licenses")] + [Description("Flag copyleft, proprietary and undeclared licences.")] + public bool Licenses { get; init; } + + [CommandOption("--transitive")] + [Description("Resolve the transitive graph (requires the dotnet CLI).")] + public bool Transitive { get; init; } + + [CommandOption("--all-checks")] + [Description("Shorthand for --audit --outdated --deprecated --licenses --transitive.")] + public bool AllChecks { get; init; } + + [CommandOption("--max-parallelism ")] + [Description("How many packages to query concurrently. Default: 8.")] + public int? MaxParallelism { get; init; } + + [CommandOption("--fail-on ")] + [Description("Exit non-zero at or above this severity: Info, Low, Moderate, High, Critical, Never.")] + public string? FailOn { get; init; } + + [CommandOption("--rules ")] + [Description("Override a rule's severity, or disable it with Rule=none. Repeatable.")] + public string[]? Rules { get; init; } + + [CommandOption("--baseline ")] + [Description("Suppress findings recorded in this baseline file.")] + public string? Baseline { get; init; } + + [CommandOption("--write-baseline")] + [Description("Record the current findings as the baseline and exit successfully.")] + public bool WriteBaseline { get; init; } + + [CommandOption("--ignore ")] + [Description("Ignore a package entirely. Supports a trailing wildcard. Repeatable.")] + public string[]? Ignore { get; init; } + + [CommandOption("--include-prerelease")] + [Description("Treat pre-release versions as acceptable upgrade targets.")] + public bool IncludePrerelease { get; init; } + + [CommandOption("--output ")] + [Description("Terminal (default), Json, Sarif, Markdown or Csv.")] + public string? Output { get; init; } + + [CommandOption("--output-file ")] + [Description("Write the structured report to a file instead of stdout.")] + public string? OutputFile { get; init; } + + [CommandOption("--fix")] + [Description("Apply every auto-fixable finding.")] + public bool Fix { get; init; } + + [CommandOption("--fix-dry-run")] + [Description("Show what --fix would change without writing.")] + public bool FixDryRun { get; init; } + + [CommandOption("--no-backup")] + [Description("Do not back up before applying fixes.")] + public bool NoBackup { get; init; } + + [CommandOption("--backup-dir ")] + [Description("Where fix backups are stored.")] + public string? BackupDir { get; init; } +} + +/// Runs the dependency health analysis and, optionally, its auto-fixes. +public sealed class AnalyzeCommand( + IAnalysisEngine engine, + IAutoFixService autoFixService, + IBaselineService baselineService, + EffectiveSettings effective +) : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, AnalyzeSettings settings) + { + Ui.Quiet = settings.Quiet; + Ui.Verbose = settings.Verbose; + + await effective.LoadAsync(settings); + + var format = ParseFormat(settings.Output); + + // Structured output goes to stdout, so progress chatter must not pollute it. + if (format != ReportFormat.Terminal && settings.OutputFile is null) + { + Ui.Quiet = true; + } + + var options = BuildOptions(settings); + var report = await RunAsync(options, format); + + if (report.ProjectCount == 0) + { + Ui.Warn("No project files were found."); + return ExitCode.NoProjectsFound; + } + + if (settings.WriteBaseline) + { + var path = settings.Baseline ?? Path.Combine(options.RootDirectory, ".centralconfig-baseline.json"); + await baselineService.WriteAsync(path, report.Findings); + Ui.Success($"Baseline written to {ReportRenderer.Relative(path)} ({report.Findings.Count} finding(s))."); + return ExitCode.Success; + } + + await EmitAsync(report, format, settings.OutputFile); + + if (settings.Fix || settings.FixDryRun) + { + var fixExit = await ApplyFixesAsync(options, report, settings); + if (fixExit != ExitCode.Success) + { + return fixExit; + } + } + + if (report.IsIncomplete && report.Findings.Count == 0) + { + return ExitCode.IncompleteAnalysis; + } + + var failOn = ParseSeverity(settings.FailOn) ?? effective.File?.FailOn ?? Severity.Info; + + if (failOn != Severity.Never && report.HighestSeverity >= failOn && report.Findings.Count > 0) + { + return ExitCode.AnalysisIssuesFound; + } + + return ExitCode.Success; + } + + private async Task RunAsync(AnalysisOptions options, ReportFormat format) + { + if (Ui.Quiet || format != ReportFormat.Terminal) + { + return await engine.AnalyzeAsync(options); + } + + return await Ui.StatusAsync( + "Analysing dependencies…", + async update => await engine.AnalyzeAsync(options, new Progress(update)) + ); + } + + private async Task ApplyFixesAsync( + AnalysisOptions options, + AnalysisReport report, + AnalyzeSettings settings + ) + { + var fixable = report.Findings.Where(f => f.IsAutoFixable).ToList(); + + if (fixable.Count == 0) + { + Ui.Info("[grey]Nothing to auto-fix.[/]"); + return ExitCode.Success; + } + + var analysisContext = await engine.BuildContextAsync(options); + var result = await autoFixService.PlanAsync(analysisContext, fixable); + + if (!result.HasChanges) + { + Ui.Info("[grey]The auto-fixable findings produced no file changes.[/]"); + return ExitCode.Success; + } + + Ui.Blank(); + Ui.Rule("Auto-fix"); + + foreach (var change in result.Changes) + { + Ui.Info($"[yellow]modify[/] {Markup.Escape(ReportRenderer.Relative(change.Path))}"); + + if (settings.Verbose || settings.FixDryRun) + { + ReportRenderer.RenderDiff(change.Diff()); + } + } + + if (settings.FixDryRun) + { + Ui.Info($"[grey]Dry run: {result.Fixed.Count} finding(s) would be fixed.[/]"); + return ExitCode.Success; + } + + var backup = await autoFixService.ApplyAsync( + result, + options.RootDirectory, + settings.BackupDir, + createBackup: !settings.NoBackup + ); + + Ui.Success($"Fixed {result.Fixed.Count} finding(s) across {result.Changes.Count} file(s)."); + + if (backup is not null) + { + Ui.Info($"[grey]Backup {backup.Id} created.[/]"); + } + + return ExitCode.Success; + } + + private static async Task EmitAsync( + AnalysisReport report, + ReportFormat format, + string? outputFile + ) + { + if (format == ReportFormat.Terminal) + { + ReportRenderer.RenderAnalysis(report); + return; + } + + var content = ReportWriterFactory.Create(format).Write(report); + + if (outputFile is null) + { + Console.Out.Write(content); + return; + } + + var directory = Path.GetDirectoryName(Path.GetFullPath(outputFile)); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + await File.WriteAllTextAsync(outputFile, content); + Ui.Success($"Report written to {ReportRenderer.Relative(outputFile)}."); + } + + private AnalysisOptions BuildOptions(AnalyzeSettings settings) + { + var all = settings.AllChecks; + + return new AnalysisOptions + { + RootDirectory = settings.ResolvedDirectory, + SolutionPath = settings.Solution, + ProjectPath = settings.Project, + ExcludePattern = effective.Text(settings.ExcludeDirs, c => c.ExcludeDirs), + Audit = all || settings.Audit, + Outdated = all || settings.Outdated, + Deprecated = all || settings.Deprecated, + Licenses = all || settings.Licenses, + Transitive = all || settings.Transitive, + MaxParallelism = settings.MaxParallelism ?? 8, + FailOn = ParseSeverity(settings.FailOn) ?? effective.File?.FailOn ?? Severity.Info, + RuleOverrides = effective.RuleOverrides(settings.Rules), + BaselinePath = settings.WriteBaseline + ? null + : effective.Text(settings.Baseline, c => c.Baseline), + IgnorePackages = (settings.Ignore ?? []).Concat(effective.File?.IgnorePackages ?? []).ToList(), + IncludePrerelease = settings.IncludePrerelease, + }; + } + + private static ReportFormat ParseFormat(string? value) => + Enum.TryParse(value, true, out var parsed) ? parsed : ReportFormat.Terminal; + + private static Severity? ParseSeverity(string? value) => + Enum.TryParse(value, true, out var parsed) ? parsed : null; +} diff --git a/CentralConfigGenerator/Cli/Commands/BackupCommands.cs b/CentralConfigGenerator/Cli/Commands/BackupCommands.cs new file mode 100644 index 0000000..f08ecfc --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/BackupCommands.cs @@ -0,0 +1,190 @@ +using System.ComponentModel; +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Services.Abstractions; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public class BackupSettings : CommandSettings +{ + [CommandOption("-d|--directory ")] + [Description("Workspace directory holding the backups. Defaults to the current directory.")] + public string? Directory { get; init; } + + [CommandOption("--backup-dir ")] + [Description("Explicit backup directory, when it is not next to the workspace.")] + public string? BackupDir { get; init; } + + [CommandOption("-q|--quiet")] + public bool Quiet { get; init; } + + public string ResolvedBackupDirectory => + System.IO.Path.GetFullPath( + BackupDir ?? Directory ?? System.IO.Directory.GetCurrentDirectory() + ); +} + +/// Lists every backup this tool has taken. +public sealed class BackupListCommand(IBackupService backupService) : Command +{ + public override int Execute(CommandContext context, BackupSettings settings) + { + Ui.Quiet = settings.Quiet; + + var backups = backupService.List(settings.ResolvedBackupDirectory); + + if (backups.Count == 0) + { + Ui.Info("[grey]No backups found.[/]"); + return ExitCode.Success; + } + + var table = new Table().Border(TableBorder.Rounded); + table.AddColumn("Id"); + table.AddColumn("Created"); + table.AddColumn("Operation"); + table.AddColumn(new TableColumn("Files").RightAligned()); + table.AddColumn(new TableColumn("Size").RightAligned()); + + foreach (var backup in backups) + { + table.AddRow( + Markup.Escape(backup.Id), + backup.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"), + Markup.Escape(backup.Operation), + backup.Files.Count.ToString(), + FormatSize(backup.SizeInBytes) + ); + } + + AnsiConsole.Write(table); + return ExitCode.Success; + } + + private static string FormatSize(long bytes) => + bytes switch + { + < 1024 => $"{bytes} B", + < 1024 * 1024 => $"{bytes / 1024.0:F1} KB", + _ => $"{bytes / (1024.0 * 1024.0):F1} MB", + }; +} + +public sealed class BackupRestoreSettings : BackupSettings +{ + [CommandArgument(0, "[BACKUP_ID]")] + [Description("Backup to restore. Defaults to the most recent one.")] + public string? BackupId { get; init; } + + [CommandOption("-f|--force")] + [Description("Skip the confirmation prompt.")] + public bool Force { get; init; } +} + +/// Restores the files captured in a backup, undoing whatever changed them. +public sealed class BackupRestoreCommand(IBackupService backupService) + : AsyncCommand +{ + public override async Task ExecuteAsync( + CommandContext context, + BackupRestoreSettings settings + ) + { + Ui.Quiet = settings.Quiet; + + var directory = settings.ResolvedBackupDirectory; + var backups = backupService.List(directory); + + if (backups.Count == 0) + { + Ui.Error("No backups found to restore."); + return ExitCode.FileOperationError; + } + + var target = settings.BackupId is null + ? backups[0] + : backups.FirstOrDefault(b => + string.Equals(b.Id, settings.BackupId, StringComparison.OrdinalIgnoreCase) + ); + + if (target is null) + { + Ui.Error($"Backup '{settings.BackupId}' was not found."); + return ExitCode.ValidationError; + } + + Ui.Info( + $"Restoring backup [bold]{Markup.Escape(target.Id)}[/] " + + $"({target.Files.Count} file(s), taken {target.CreatedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss} by '{Markup.Escape(target.Operation)}')." + ); + + if (!settings.Force && !Ui.Confirm("This overwrites the current files. Continue?", defaultValue: false)) + { + Ui.Warn("Cancelled."); + return ExitCode.Success; + } + + var restored = await backupService.RestoreAsync(directory, target.Id); + + if (restored is null) + { + Ui.Error("The backup manifest could not be read."); + return ExitCode.FileOperationError; + } + + Ui.Success($"Restored {restored.Files.Count} file(s) from backup {restored.Id}."); + return ExitCode.Success; + } +} + +public sealed class BackupPruneSettings : BackupSettings +{ + [CommandOption("--retention ")] + [Description("How many backups to keep. Default: 5.")] + public int? Retention { get; init; } + + [CommandOption("--all")] + [Description("Delete every backup.")] + public bool All { get; init; } + + [CommandOption("-f|--force")] + public bool Force { get; init; } +} + +/// Deletes old backup sets. +public sealed class BackupPruneCommand(IBackupService backupService) : Command +{ + public override int Execute(CommandContext context, BackupPruneSettings settings) + { + Ui.Quiet = settings.Quiet; + + var directory = settings.ResolvedBackupDirectory; + var retention = settings.All ? 0 : settings.Retention ?? 5; + var existing = backupService.List(directory); + var doomed = existing.Skip(retention).ToList(); + + if (doomed.Count == 0) + { + Ui.Info($"[grey]Nothing to prune ({existing.Count} backup(s), keeping {retention}).[/]"); + return ExitCode.Success; + } + + if ( + !settings.Force + && !Ui.Confirm($"Delete {doomed.Count} backup set(s)? This cannot be undone.", defaultValue: false) + ) + { + Ui.Warn("Cancelled."); + return ExitCode.Success; + } + + var removed = settings.All + ? backupService.PruneAll(directory) + : backupService.Prune(directory, retention); + + Ui.Success($"Deleted {removed.Count} backup set(s)."); + return ExitCode.Success; + } +} diff --git a/CentralConfigGenerator/Cli/Commands/BatchCommand.cs b/CentralConfigGenerator/Cli/Commands/BatchCommand.cs new file mode 100644 index 0000000..5e89df8 --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/BatchCommand.cs @@ -0,0 +1,159 @@ +using System.ComponentModel; +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Cli.Rendering; +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Workspace; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public sealed class BatchSettings : MutatingSettings +{ + [CommandArgument(0, "[ROOT]")] + [Description("Root directory containing several repositories or solutions.")] + public string? Root { get; init; } + + [CommandOption("--parallel")] + [Description("Process workspaces concurrently.")] + public bool Parallel { get; init; } + + [CommandOption("--continue")] + [Description("Keep going after a workspace fails.")] + public bool ContinueOnError { get; init; } + + [CommandOption("--max-depth ")] + [Description("How deep to search for solutions. Default: 4.")] + public int? MaxDepth { get; init; } + + [CommandOption("--list")] + [Description("List the workspaces that would be processed, then stop.")] + public bool List { get; init; } + + [CommandOption("-t|--transitive-pinning")] + [Description("Enable CentralPackageTransitivePinningEnabled in every generated file.")] + public bool TransitivePinning { get; init; } +} + +/// Runs the migration across every solution in a monorepo. +public sealed class BatchCommand( + BatchService batchService, + IMigrationService migrationService, + EffectiveSettings effective +) : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, BatchSettings settings) + { + Ui.Quiet = settings.Quiet; + Ui.Verbose = settings.Verbose; + + await effective.LoadAsync(settings); + + var root = Path.GetFullPath(settings.Root ?? settings.ResolvedDirectory); + + var batchOptions = new BatchOptions + { + RootDirectory = root, + Parallel = settings.Parallel, + ContinueOnError = settings.ContinueOnError, + ExcludePattern = effective.Text(settings.ExcludeDirs, c => c.ExcludeDirs), + MaxDepth = settings.MaxDepth ?? 4, + }; + + var workspaces = batchService.DiscoverWorkspaces(batchOptions); + + if (workspaces.Count == 0) + { + Ui.Warn($"No solutions or projects found beneath {ReportRenderer.Relative(root)}."); + return ExitCode.NoProjectsFound; + } + + Ui.Info($"Found [bold]{workspaces.Count}[/] workspace(s)."); + + if (settings.List) + { + foreach (var workspace in workspaces) + { + Ui.Info($" {Markup.Escape(ReportRenderer.Relative(workspace))}"); + } + + return ExitCode.Success; + } + + if ( + !settings.Force + && !settings.DryRun + && !Ui.Confirm($"Migrate all {workspaces.Count} workspace(s)?", defaultValue: false) + ) + { + Ui.Warn("Cancelled."); + return ExitCode.Success; + } + + var result = await batchService.RunAsync( + batchOptions, + async (workspace, token) => + { + var options = new MigrationOptions + { + RootDirectory = workspace, + ExcludePattern = batchOptions.ExcludePattern, + Merge = true, + TransitivePinning = effective.Bool( + settings.TransitivePinning, + c => c.TransitivePinning + ), + CreateBackup = !settings.NoBackup && effective.File?.Backup != false, + BackupDirectory = settings.BackupDir, + }; + + var plan = await migrationService.PlanAsync(options, null, token); + + if (!settings.DryRun && plan.HasChanges) + { + await migrationService.ApplyAsync(plan, options, token); + } + + return plan; + }, + new Progress(workspace => + Ui.Detail($"→ {ReportRenderer.Relative(workspace)}") + ) + ); + + var table = new Table().Border(TableBorder.Rounded); + table.AddColumn("Workspace"); + table.AddColumn(new TableColumn("Projects").RightAligned()); + table.AddColumn(new TableColumn("Packages").RightAligned()); + table.AddColumn(new TableColumn("Changes").RightAligned()); + table.AddColumn("Result"); + + foreach (var item in result.Items) + { + table.AddRow( + Markup.Escape(ReportRenderer.Relative(item.Workspace)), + item.Plan?.ProjectCount.ToString() ?? "-", + item.Plan?.PackageCount.ToString() ?? "-", + item.Plan?.Changes.Count.ToString() ?? "-", + item.Succeeded + ? settings.DryRun + ? "[grey]dry run[/]" + : "[green]ok[/]" + : $"[red]{Markup.Escape(item.Error ?? "failed")}[/]" + ); + } + + AnsiConsole.Write(table); + Ui.Blank(); + + if (result.FailedCount > 0) + { + Ui.Error($"{result.FailedCount} workspace(s) failed, {result.SucceededCount} succeeded."); + return ExitCode.UnexpectedError; + } + + Ui.Success($"{result.SucceededCount} workspace(s) processed."); + return ExitCode.Success; + } +} diff --git a/CentralConfigGenerator/Cli/Commands/BuildPropsCommand.cs b/CentralConfigGenerator/Cli/Commands/BuildPropsCommand.cs new file mode 100644 index 0000000..bc647a5 --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/BuildPropsCommand.cs @@ -0,0 +1,127 @@ +using System.ComponentModel; +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Cli.Rendering; +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Models; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public sealed class BuildPropsSettings : MutatingSettings +{ + [CommandOption("-o|--output-dir ")] + [Description("Where Directory.Build.props is written. Defaults to the scanned directory.")] + public string? OutputDir { get; init; } + + [CommandOption("--overwrite")] + [Description("Replace an existing Directory.Build.props.")] + public bool Overwrite { get; init; } + + [CommandOption("-m|--merge")] + [Description("Merge into an existing Directory.Build.props.")] + public bool Merge { get; init; } + + [CommandOption("--property ")] + [Description("Property to consider for hoisting. Repeatable; replaces the default list.")] + public string[]? Properties { get; init; } + + [CommandOption("--keep-in-projects")] + [Description("Write Directory.Build.props but leave the projects untouched.")] + public bool KeepInProjects { get; init; } +} + +/// Hoists MSBuild properties shared by every project into Directory.Build.props. +public sealed class BuildPropsCommand( + IMigrationService migrationService, + EffectiveSettings effective +) : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, BuildPropsSettings settings) + { + Ui.Quiet = settings.Quiet; + Ui.Verbose = settings.Verbose; + + await effective.LoadAsync(settings); + + var options = new MigrationOptions + { + RootDirectory = settings.ResolvedDirectory, + SolutionPath = settings.Solution, + ProjectPath = settings.Project, + OutputDirectory = settings.OutputDir, + ExcludePattern = effective.Text(settings.ExcludeDirs, c => c.ExcludeDirs), + Merge = settings.Merge, + // Only the property half of the migration is wanted here. + KeepInlineVersions = true, + UnifyProperties = true, + CreateBackup = !settings.NoBackup && effective.File?.Backup != false, + BackupDirectory = effective.Text(settings.BackupDir, c => c.BackupDir), + AddGitIgnore = effective.Bool(settings.AddGitIgnore, c => c.AddGitignore), + BuildProperties = settings.Properties + ?? effective.File?.BuildProperties + ?? BuildPropertyDefaults.Recommended, + }; + + if (!settings.Merge && !settings.Overwrite && !settings.DryRun && File.Exists(options.BuildPropsPath)) + { + Ui.Warn( + "Directory.Build.props already exists. Use --merge to combine, or --overwrite to replace it." + ); + return ExitCode.ValidationError; + } + + var plan = await Ui.StatusAsync( + "Looking for shared properties…", + async _ => await migrationService.PlanAsync(options) + ); + + if (plan.ProjectCount == 0) + { + Ui.Warn("No project files were found."); + return ExitCode.NoProjectsFound; + } + + // Drop the Directory.Packages.props change; this command is about properties only. + var changes = plan + .Changes.Where(c => + !string.Equals(c.Path, options.PackagesPropsPath, StringComparison.OrdinalIgnoreCase) + ) + .Where(c => + !settings.KeepInProjects + || string.Equals(c.Path, options.BuildPropsPath, StringComparison.OrdinalIgnoreCase) + ) + .ToList(); + + if (changes.Count == 0) + { + Ui.Warn( + "No property is declared identically by two or more projects, so nothing can be hoisted safely." + ); + return ExitCode.Success; + } + + var trimmed = plan with { Changes = changes }; + + ReportRenderer.RenderPlan(trimmed, settings.Diff, options.RootDirectory); + + if (settings.DryRun) + { + Ui.Blank(); + Ui.Info("[grey]Dry run: no files were written.[/]"); + return ExitCode.Success; + } + + var backup = await migrationService.ApplyAsync(trimmed, options); + + Ui.Blank(); + Ui.Success($"Wrote {changes.Count} file(s)."); + + if (backup is not null) + { + Ui.Info($"[grey]Backup {backup.Id} created.[/]"); + } + + return ExitCode.Success; + } +} diff --git a/CentralConfigGenerator/Cli/Commands/CompletionsCommand.cs b/CentralConfigGenerator/Cli/Commands/CompletionsCommand.cs new file mode 100644 index 0000000..3821c03 --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/CompletionsCommand.cs @@ -0,0 +1,181 @@ +using System.ComponentModel; +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Core.Models; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public sealed class CompletionsSettings : CommandSettings +{ + [CommandArgument(0, "")] + [Description("bash, zsh, fish or powershell.")] + public string Shell { get; init; } = string.Empty; +} + +/// Emits a shell completion script for the tool. +public sealed class CompletionsCommand : Command +{ + private static readonly string[] Commands = + [ + "migrate", + "packages", + "build", + "all", + "revert", + "analyze", + "update", + "verify", + "tree", + "status", + "doctor", + "init", + "explain", + "batch", + "backups", + "completions", + ]; + + private static readonly string[] Options = + [ + "--directory", + "--solution", + "--project", + "--exclude-dirs", + "--dry-run", + "--diff", + "--merge", + "--overwrite", + "--keep-attrs", + "--conflict-strategy", + "--interactive-conflicts", + "--ignore-prerelease", + "--transitive-pinning", + "--encoding", + "--linewrap", + "--unify-props", + "--audit", + "--outdated", + "--deprecated", + "--licenses", + "--transitive", + "--all-checks", + "--fail-on", + "--rules", + "--baseline", + "--write-baseline", + "--output", + "--output-file", + "--fix", + "--fix-dry-run", + "--bisect", + "--bisect-budget", + "--only", + "--no-backup", + "--backup-dir", + "--add-gitignore", + "--force", + "--quiet", + "--verbose", + "--help", + ]; + + public override int Execute(CommandContext context, CompletionsSettings settings) + { + var script = Render(settings.Shell); + + if (script is null) + { + Ui.Error($"Unsupported shell '{settings.Shell}'. Choose bash, zsh, fish or powershell."); + return ExitCode.ValidationError; + } + + Console.Out.Write(script); + return ExitCode.Success; + } + + /// Returns the completion script for a shell, or null when it is not supported. + internal static string? Render(string shell) => + shell.ToLowerInvariant() switch + { + "bash" => Bash(), + "zsh" => Zsh(), + "fish" => Fish(), + "powershell" or "pwsh" => PowerShell(), + _ => null, + }; + + private static string Bash() => + """ + # central-config bash completion + # Install: central-config completions bash > /etc/bash_completion.d/central-config + _central_config_completions() + { + local cur commands options + cur="${COMP_WORDS[COMP_CWORD]}" + commands="__COMMANDS__" + options="__OPTIONS__" + + if [[ ${COMP_CWORD} -eq 1 ]]; then + COMPREPLY=( $(compgen -W "${commands}" -- "${cur}") ) + else + COMPREPLY=( $(compgen -W "${options}" -- "${cur}") ) + fi + return 0 + } + complete -F _central_config_completions central-config + + """ + .Replace("__COMMANDS__", string.Join(" ", Commands)) + .Replace("__OPTIONS__", string.Join(" ", Options)); + + private static string Zsh() => + """ + #compdef central-config + # Install: central-config completions zsh > "${fpath[1]}/_central-config" + _central_config() { + local -a commands options + commands=(__COMMANDS__) + options=(__OPTIONS__) + + if (( CURRENT == 2 )); then + _describe 'command' commands + else + _describe 'option' options + fi + } + compdef _central_config central-config + + """ + .Replace("__COMMANDS__", string.Join(" ", Commands)) + .Replace("__OPTIONS__", string.Join(" ", Options)); + + private static string Fish() => + string.Join( + "\n", + new[] { "# central-config fish completion", "# Install: central-config completions fish > ~/.config/fish/completions/central-config.fish" } + .Concat( + Commands.Select(c => + $"complete -c central-config -n '__fish_use_subcommand' -a '{c}'" + ) + ) + .Concat( + Options.Select(o => $"complete -c central-config -l '{o.TrimStart('-')}'") + ) + ) + "\n"; + + private static string PowerShell() => + $$""" + # central-config PowerShell completion + # Install: central-config completions powershell | Out-String | Invoke-Expression + Register-ArgumentCompleter -Native -CommandName central-config -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + $commands = @({{string.Join(", ", Commands.Select(c => $"'{c}'"))}}) + $options = @({{string.Join(", ", Options.Select(o => $"'{o}'"))}}) + $candidates = if ($commandAst.CommandElements.Count -le 2) { $commands + $options } else { $options } + $candidates | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } + } + + """; +} diff --git a/CentralConfigGenerator/Cli/Commands/DoctorCommand.cs b/CentralConfigGenerator/Cli/Commands/DoctorCommand.cs new file mode 100644 index 0000000..a4dcf6d --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/DoctorCommand.cs @@ -0,0 +1,65 @@ +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Workspace; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public sealed class DoctorSettings : WorkspaceSettings; + +/// Environment diagnostics: run this first when something behaves unexpectedly. +public sealed class DoctorCommand(DoctorService doctorService) : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, DoctorSettings settings) + { + Ui.Quiet = settings.Quiet; + Ui.Verbose = settings.Verbose; + + var report = await Ui.StatusAsync( + "Running diagnostics…", + async _ => await doctorService.RunAsync(settings.ResolvedDirectory) + ); + + var table = new Table().Border(TableBorder.Rounded).Expand(); + table.AddColumn(new TableColumn("").Width(3)); + table.AddColumn("Check"); + table.AddColumn("Result"); + + foreach (var check in report.Checks) + { + var detail = check.Remedy is null + ? Markup.Escape(check.Detail) + : $"{Markup.Escape(check.Detail)}\n[grey]→ {Markup.Escape(check.Remedy)}[/]"; + + table.AddRow(Icon(check.Status), Markup.Escape(check.Name), detail); + } + + AnsiConsole.Write(table); + + if (report.HasFailures) + { + Ui.Blank(); + Ui.Error("One or more checks failed. Fix those before migrating."); + return ExitCode.ValidationError; + } + + Ui.Blank(); + Ui.Success( + report.HasWarnings + ? "No blocking problems, but some checks have suggestions." + : "Everything looks good." + ); + + return ExitCode.Success; + } + + private static string Icon(CheckStatus status) => + status switch + { + CheckStatus.Pass => "[green]✓[/]", + CheckStatus.Warn => "[yellow]![/]", + CheckStatus.Fail => "[red]✗[/]", + _ => "[grey]-[/]", + }; +} diff --git a/CentralConfigGenerator/Cli/Commands/ExplainCommand.cs b/CentralConfigGenerator/Cli/Commands/ExplainCommand.cs new file mode 100644 index 0000000..6f5d8e5 --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/ExplainCommand.cs @@ -0,0 +1,84 @@ +using System.ComponentModel; +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Cli.Rendering; +using CentralConfigGenerator.Core.Analysis; +using CentralConfigGenerator.Core.Models; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public sealed class ExplainSettings : CommandSettings +{ + [CommandArgument(0, "[RULE]")] + [Description("Rule id to explain, or 'all' to list every rule.")] + public string? Rule { get; init; } +} + +/// Documents what a rule looks for and why it matters. +public sealed class ExplainCommand : Command +{ + public override int Execute(CommandContext context, ExplainSettings settings) + { + var rules = RuleCatalog.CreateAll(); + + if (settings.Rule is null || string.Equals(settings.Rule, "all", StringComparison.OrdinalIgnoreCase)) + { + var table = new Table().Border(TableBorder.Rounded).Expand(); + table.AddColumn("Rule"); + table.AddColumn("Default"); + table.AddColumn("What it finds"); + table.AddColumn(new TableColumn("Needs").Centered()); + + foreach (var rule in rules) + { + table.AddRow( + Markup.Escape(rule.RuleId), + ReportRenderer.Colorize(rule.DefaultSeverity), + Markup.Escape(rule.Description), + rule.RequiresNetwork ? "[yellow]feed[/]" : "[grey]-[/]" + ); + } + + AnsiConsole.Write(table); + AnsiConsole.MarkupLine( + "\n[grey]Run 'central-config explain ' for the full rationale, " + + "or set severities with --rules RuleId=Severity (or =none to disable).[/]" + ); + + return ExitCode.Success; + } + + var match = RuleCatalog.Find(settings.Rule); + + if (match is null) + { + Ui.Error($"Unknown rule '{settings.Rule}'."); + + var suggestion = rules + .Select(r => r.RuleId) + .FirstOrDefault(id => id.Contains(settings.Rule, StringComparison.OrdinalIgnoreCase)); + + if (suggestion is not null) + { + Ui.Info($"Did you mean [bold]{suggestion}[/]?"); + } + + Ui.Info("[grey]Run 'central-config explain all' to list every rule.[/]"); + return ExitCode.ValidationError; + } + + var grid = new Grid().AddColumn(new GridColumn().NoWrap().PadRight(2)).AddColumn(); + grid.AddRow("[bold]Rule[/]", Markup.Escape(match.RuleId)); + grid.AddRow("[bold]Title[/]", Markup.Escape(match.Title)); + grid.AddRow("[bold]Default severity[/]", ReportRenderer.Colorize(match.DefaultSeverity)); + grid.AddRow("[bold]Needs the feed[/]", match.RequiresNetwork ? "yes" : "no"); + grid.AddRow("[bold]Needs CPM[/]", match.RequiresCentralPackageManagement ? "yes" : "no"); + grid.AddRow("[bold]Detects[/]", Markup.Escape(match.Description)); + grid.AddRow("[bold]Why it matters[/]", Markup.Escape(match.Rationale)); + + AnsiConsole.Write(new Panel(grid).Header($"[bold]{Markup.Escape(match.Title)}[/]").Border(BoxBorder.Rounded)); + + return ExitCode.Success; + } +} diff --git a/CentralConfigGenerator/Cli/Commands/InitCommand.cs b/CentralConfigGenerator/Cli/Commands/InitCommand.cs new file mode 100644 index 0000000..9053e0f --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/InitCommand.cs @@ -0,0 +1,47 @@ +using System.ComponentModel; +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Cli.Rendering; +using CentralConfigGenerator.Core.Configuration; +using CentralConfigGenerator.Core.Models; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public sealed class InitSettings : CommandSettings +{ + [CommandOption("-d|--directory ")] + [Description("Where the config file is written. Defaults to the current directory.")] + public string? Directory { get; init; } + + [CommandOption("-f|--force")] + [Description("Overwrite an existing .centralconfig.json.")] + public bool Force { get; init; } + + [CommandOption("-q|--quiet")] + public bool Quiet { get; init; } +} + +/// Scaffolds a .centralconfig.json so the team shares one set of defaults. +public sealed class InitCommand(ConfigurationLoader loader) : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, InitSettings settings) + { + Ui.Quiet = settings.Quiet; + + var directory = Path.GetFullPath(settings.Directory ?? System.IO.Directory.GetCurrentDirectory()); + var path = Path.Combine(directory, ConfigurationLoader.FileName); + + if (File.Exists(path) && !settings.Force) + { + Ui.Warn($"{ConfigurationLoader.FileName} already exists. Use --force to overwrite."); + return ExitCode.ValidationError; + } + + var written = await loader.ScaffoldAsync(directory, settings.Force); + + Ui.Success($"Created {ReportRenderer.Relative(written)}."); + Ui.Info("[grey]Edit it to set your conflict strategy, rule severities and backup retention.[/]"); + + return ExitCode.Success; + } +} diff --git a/CentralConfigGenerator/Cli/Commands/MigrateCommand.cs b/CentralConfigGenerator/Cli/Commands/MigrateCommand.cs new file mode 100644 index 0000000..399a64a --- /dev/null +++ b/CentralConfigGenerator/Cli/Commands/MigrateCommand.cs @@ -0,0 +1,253 @@ +using System.ComponentModel; +using CentralConfigGenerator.Cli.Infrastructure; +using CentralConfigGenerator.Cli.Rendering; +using CentralConfigGenerator.Core.Migration; +using CentralConfigGenerator.Core.Models; +using CentralConfigGenerator.Core.Services; +using NuGet.Versioning; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace CentralConfigGenerator.Cli.Commands; + +public class MigrateSettings : MutatingSettings +{ + [CommandOption("-o|--output-dir ")] + [Description("Where Directory.Packages.props is written. Defaults to the scanned directory.")] + public string? OutputDir { get; init; } + + [CommandOption("-m|--merge")] + [Description("Merge into an existing Directory.Packages.props instead of replacing it.")] + public bool Merge { get; init; } + + [CommandOption("--overwrite")] + [Description("Replace an existing Directory.Packages.props.")] + public bool Overwrite { get; init; } + + [CommandOption("-k|--keep-attrs")] + [Description("Leave inline Version attributes in the project files.")] + public bool KeepAttributes { get; init; } + + [CommandOption("--conflict-strategy ")] + [Description("Highest (default), Lowest, MostCommon or Fail.")] + public string? ConflictStrategy { get; init; } + + [CommandOption("--interactive-conflicts")] + [Description("Prompt for every version conflict instead of resolving automatically.")] + public bool InteractiveConflicts { get; init; } + + [CommandOption("--min-version")] + [Description("Shorthand for --conflict-strategy Lowest.")] + public bool MinVersion { get; init; } + + [CommandOption("--ignore-prerelease")] + [Description("Prefer stable versions when resolving conflicts.")] + public bool IgnorePrerelease { get; init; } + + [CommandOption("-t|--transitive-pinning")] + [Description("Enable CentralPackageTransitivePinningEnabled in the generated file.")] + public bool TransitivePinning { get; init; } + + [CommandOption("-e|--encoding ")] + [Description("Write files with this encoding (IANA name, e.g. utf-8). Default: preserve.")] + public string? Encoding { get; init; } + + [CommandOption("-l|--linewrap