diff --git a/src/ClaudeDo.Releases/SelfUpdateResult.cs b/src/ClaudeDo.Releases/SelfUpdateResult.cs index 82acdca..6d2f96e 100644 --- a/src/ClaudeDo.Releases/SelfUpdateResult.cs +++ b/src/ClaudeDo.Releases/SelfUpdateResult.cs @@ -1,3 +1,15 @@ namespace ClaudeDo.Releases; public sealed record InstallerAssetMatch(ReleaseAsset Asset, string Version); + +public enum SelfUpdateDecisionKind +{ + NoUpdate, + UpdateAvailable, +} + +public sealed record SelfUpdateDecision( + SelfUpdateDecisionKind Kind, + string? LatestVersion = null, + ReleaseAsset? InstallerAsset = null, + ReleaseAsset? ChecksumsAsset = null); diff --git a/src/ClaudeDo.Releases/SelfUpdater.cs b/src/ClaudeDo.Releases/SelfUpdater.cs index b5df89d..71d18f2 100644 --- a/src/ClaudeDo.Releases/SelfUpdater.cs +++ b/src/ClaudeDo.Releases/SelfUpdater.cs @@ -1,3 +1,4 @@ +using System.Net.Http; using System.Text.RegularExpressions; namespace ClaudeDo.Releases; @@ -19,4 +20,40 @@ public static partial class SelfUpdater } return null; } + + public static async Task DecideUpdateAsync( + IReleaseClient releases, + string currentVersion, + CancellationToken ct) + { + GiteaRelease? release; + try + { + release = await releases.GetLatestReleaseAsync(ct); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate); + } + + if (release is null) + return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate); + + var match = FindInstallerAsset(release.Assets); + if (match is null) + return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate); + + var cmp = VersionComparer.Compare(match.Version, currentVersion); + if (!cmp.IsNewer) + return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate); + + var checksums = release.Assets.FirstOrDefault( + a => string.Equals(a.Name, "checksums.txt", StringComparison.OrdinalIgnoreCase)); + + return new SelfUpdateDecision( + SelfUpdateDecisionKind.UpdateAvailable, + LatestVersion: match.Version, + InstallerAsset: match.Asset, + ChecksumsAsset: checksums); + } } diff --git a/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs b/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs index 9abaf70..efb46de 100644 --- a/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs +++ b/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs @@ -1,3 +1,5 @@ +using System.Net.Http; + namespace ClaudeDo.Releases.Tests; public class SelfUpdaterAssetMatchingTests @@ -42,3 +44,83 @@ public class SelfUpdaterAssetMatchingTests Assert.Null(SelfUpdater.FindInstallerAsset(assets)); } } + +public class SelfUpdaterDecisionTests +{ + private sealed class FakeReleaseClient : IReleaseClient + { + public GiteaRelease? Release { get; set; } + public bool Throw { get; set; } + + public Task GetLatestReleaseAsync(CancellationToken ct) + { + if (Throw) throw new HttpRequestException("boom"); + return Task.FromResult(Release); + } + + public Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct) + => throw new NotSupportedException("not used in decision tests"); + } + + [Fact] + public async Task Decide_NoRelease_NoUpdate() + { + var client = new FakeReleaseClient { Release = null }; + var d = await SelfUpdater.DecideUpdateAsync(client, currentVersion: "0.1.0", CancellationToken.None); + Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind); + } + + [Fact] + public async Task Decide_NetworkError_NoUpdate() + { + var client = new FakeReleaseClient { Throw = true }; + var d = await SelfUpdater.DecideUpdateAsync(client, "0.1.0", CancellationToken.None); + Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind); + } + + [Fact] + public async Task Decide_OlderLatest_NoUpdate() + { + var client = new FakeReleaseClient + { + Release = new GiteaRelease("v0.1.0", "rel", new[] + { + new ReleaseAsset("ClaudeDo.Installer-0.1.0.exe", "u", 1), + }), + }; + var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None); + Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind); + } + + [Fact] + public async Task Decide_NewerLatestWithAsset_UpdateAvailable() + { + var client = new FakeReleaseClient + { + Release = new GiteaRelease("v0.3.0", "rel", new[] + { + new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x", 20), + new ReleaseAsset("checksums.txt", "https://checks", 1), + }), + }; + var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None); + Assert.Equal(SelfUpdateDecisionKind.UpdateAvailable, d.Kind); + Assert.Equal("0.3.0", d.LatestVersion); + Assert.NotNull(d.InstallerAsset); + Assert.NotNull(d.ChecksumsAsset); + } + + [Fact] + public async Task Decide_NewerLatestButNoInstallerAsset_NoUpdate() + { + var client = new FakeReleaseClient + { + Release = new GiteaRelease("v0.3.0", "rel", new[] + { + new ReleaseAsset("ClaudeDo-0.3.0-win-x64.zip", "u", 20), + }), + }; + var d = await SelfUpdater.DecideUpdateAsync(client, "0.2.0", CancellationToken.None); + Assert.Equal(SelfUpdateDecisionKind.NoUpdate, d.Kind); + } +}