diff --git a/KtsuBuild.Tests/Metadata/LicenseGeneratorTests.cs b/KtsuBuild.Tests/Metadata/LicenseGeneratorTests.cs index 89dcd4c..3dd508f 100644 --- a/KtsuBuild.Tests/Metadata/LicenseGeneratorTests.cs +++ b/KtsuBuild.Tests/Metadata/LicenseGeneratorTests.cs @@ -167,4 +167,65 @@ await LicenseGenerator.GenerateAsync( Assert.IsTrue(content.Contains("WITHOUT WARRANTY"), "Should contain warranty disclaimer"); Assert.IsTrue(content.Contains("THE SOFTWARE IS PROVIDED"), "Should contain software provision clause"); } + + [TestMethod] + public async Task GenerateAsync_NewYear_KeepsExistingCopyright() + { + // Arrange + const string Existing = "Copyright (c) 2023-2026 testowner contributors"; + await File.WriteAllTextAsync(Path.Combine(_tempDir, "COPYRIGHT.md"), Existing + "\n").ConfigureAwait(false); + + // Act + await LicenseGenerator.GenerateAsync( + serverUrl: "https://github.com/testowner", + owner: "testowner", + repository: "testrepo", + outputPath: _tempDir, + lineEnding: "\n", + currentYear: 2027).ConfigureAwait(false); + + // Assert + string copyrightContent = await File.ReadAllTextAsync(Path.Combine(_tempDir, "COPYRIGHT.md")).ConfigureAwait(false); + string licenseContent = await File.ReadAllTextAsync(Path.Combine(_tempDir, "LICENSE.md")).ConfigureAwait(false); + Assert.AreEqual(Existing + "\n", copyrightContent, "An existing COPYRIGHT.md must not move to the new year"); + Assert.IsTrue(licenseContent.Contains(Existing), "LICENSE.md should carry the existing copyright line"); + Assert.IsFalse(licenseContent.Contains("2027"), "LICENSE.md should not move to the new year"); + } + + [TestMethod] + public async Task GenerateAsync_NoCopyrightFile_GeneratesOneForTheCurrentYear() + { + // Act + await LicenseGenerator.GenerateAsync( + serverUrl: "https://github.com/testowner", + owner: "testowner", + repository: "testrepo", + outputPath: _tempDir, + lineEnding: "\n", + currentYear: 2027).ConfigureAwait(false); + + // Assert + string copyrightContent = await File.ReadAllTextAsync(Path.Combine(_tempDir, "COPYRIGHT.md")).ConfigureAwait(false); + Assert.AreEqual("Copyright (c) 2023-2027 testowner contributors\n", copyrightContent); + } + + [TestMethod] + public async Task GenerateAsync_EmptyCopyrightFile_IsRegenerated() + { + // Arrange + await File.WriteAllTextAsync(Path.Combine(_tempDir, "COPYRIGHT.md"), " \n").ConfigureAwait(false); + + // Act + await LicenseGenerator.GenerateAsync( + serverUrl: "https://github.com/testowner", + owner: "testowner", + repository: "testrepo", + outputPath: _tempDir, + lineEnding: "\n", + currentYear: 2027).ConfigureAwait(false); + + // Assert + string copyrightContent = await File.ReadAllTextAsync(Path.Combine(_tempDir, "COPYRIGHT.md")).ConfigureAwait(false); + Assert.AreEqual("Copyright (c) 2023-2027 testowner contributors\n", copyrightContent); + } } diff --git a/KtsuBuild/Metadata/LicenseGenerator.cs b/KtsuBuild/Metadata/LicenseGenerator.cs index e090439..c84b5d0 100644 --- a/KtsuBuild/Metadata/LicenseGenerator.cs +++ b/KtsuBuild/Metadata/LicenseGenerator.cs @@ -16,6 +16,12 @@ public static class LicenseGenerator /// /// Generates LICENSE.md and COPYRIGHT.md files. /// + /// + /// An existing COPYRIGHT.md is kept as it is, and its text is what LICENSE.md carries. ktsu.Sdk derives + /// every source file's required header from COPYRIGHT.md, so rewriting it from the clock would fail + /// every file in the repository against IDE0073 the first time a run lands in a new year. Moving the + /// year on is a deliberate change that also rewrites the headers, not a side effect of a build. + /// /// The GitHub server URL. /// The repository owner. /// The repository name. @@ -29,6 +35,26 @@ public static async Task GenerateAsync( string repository, string outputPath, string lineEnding, + CancellationToken cancellationToken = default) => + await GenerateAsync(serverUrl, owner, repository, outputPath, lineEnding, DateTime.UtcNow.Year, cancellationToken).ConfigureAwait(false); + + /// + /// Generates LICENSE.md and COPYRIGHT.md files, taking the current year from the caller. + /// + /// The GitHub server URL. + /// The repository owner. + /// The repository name. + /// The output directory. + /// The line ending to use. + /// The year a newly created COPYRIGHT.md runs to. + /// A cancellation token. + internal static async Task GenerateAsync( + string serverUrl, + string owner, + string repository, + string outputPath, + string lineEnding, + int currentYear, CancellationToken cancellationToken = default) { Ensure.NotNull(serverUrl); @@ -38,13 +64,18 @@ public static async Task GenerateAsync( Ensure.NotNull(lineEnding); string template = LicenseTemplate; - int year = DateTime.UtcNow.Year; // Build project URL string projectUrl = $"{serverUrl}/{repository}"; - // Build copyright string - string copyright = $"Copyright (c) 2023-{year} {owner} contributors"; + // Keep an existing copyright line, and make one up only for a repository that has none + string copyrightPath = Path.Combine(outputPath, "COPYRIGHT.md"); + string? existingCopyright = File.Exists(copyrightPath) + ? (await File.ReadAllTextAsync(copyrightPath, cancellationToken).ConfigureAwait(false)).Trim() + : null; + string copyright = string.IsNullOrEmpty(existingCopyright) + ? $"Copyright (c) 2023-{currentYear} {owner} contributors" + : existingCopyright!; // Replace placeholders string licenseContent = template @@ -56,7 +87,6 @@ public static async Task GenerateAsync( await LineEndingHelper.WriteFileAsync(licensePath, licenseContent, lineEnding, cancellationToken).ConfigureAwait(false); // Write COPYRIGHT.md - string copyrightPath = Path.Combine(outputPath, "COPYRIGHT.md"); await LineEndingHelper.WriteFileAsync(copyrightPath, copyright + lineEnding, lineEnding, cancellationToken).ConfigureAwait(false); }