diff --git a/src/ClaudeDo.Installer/Checks/CheckResult.cs b/src/ClaudeDo.Installer/Checks/CheckResult.cs new file mode 100644 index 00000000..15e612a0 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/CheckResult.cs @@ -0,0 +1,25 @@ +namespace ClaudeDo.Installer.Checks; + +public enum CheckSeverity { Warning, Error } + +public enum CheckStatus { Ok, Failed, Unknown } + +public sealed record CheckResult( + string Id, + CheckSeverity Severity, + CheckStatus Status, + string TitleKey, + string Message, + string? HintKey, + string? HelpUrl, + string? Detail) +{ + public static CheckResult Ok(string id, CheckSeverity severity, string titleKey, string message, string? detail = null) => + new(id, severity, CheckStatus.Ok, titleKey, message, HintKey: null, HelpUrl: null, detail); + + public static CheckResult Fail(string id, CheckSeverity severity, string titleKey, string message, string? hintKey = null, string? helpUrl = null, string? detail = null) => + new(id, severity, CheckStatus.Failed, titleKey, message, hintKey, helpUrl, detail); + + public static CheckResult Unknown(string id, CheckSeverity severity, string titleKey, string message, string? detail = null) => + new(id, severity, CheckStatus.Unknown, titleKey, message, HintKey: null, HelpUrl: null, detail); +} diff --git a/src/ClaudeDo.Installer/Checks/EnvironmentCheckService.cs b/src/ClaudeDo.Installer/Checks/EnvironmentCheckService.cs new file mode 100644 index 00000000..f21cabc5 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/EnvironmentCheckService.cs @@ -0,0 +1,37 @@ +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Checks; + +public sealed record EnvironmentCheckReport(IReadOnlyList Results) +{ + public bool HasBlockingError => Results.Any(r => r.Severity == CheckSeverity.Error && r.Status == CheckStatus.Failed); +} + +public sealed class EnvironmentCheckService +{ + private readonly IReadOnlyList _checks; + + public EnvironmentCheckService(IEnumerable checks) + { + _checks = checks.ToList(); + } + + public async Task RunAllAsync(InstallContext ctx, CancellationToken ct) + { + var tasks = _checks.Select(check => RunSafeAsync(check, ctx, ct)).ToArray(); + var results = await Task.WhenAll(tasks); + return new EnvironmentCheckReport(results); + } + + private static async Task RunSafeAsync(IEnvironmentCheck check, InstallContext ctx, CancellationToken ct) + { + try + { + return await check.RunAsync(ctx, ct); + } + catch (Exception ex) + { + return CheckResult.Unknown(check.Id, check.Severity, string.Empty, ex.Message, ex.Message); + } + } +} diff --git a/src/ClaudeDo.Installer/Checks/Interfaces/IEnvironmentCheck.cs b/src/ClaudeDo.Installer/Checks/Interfaces/IEnvironmentCheck.cs new file mode 100644 index 00000000..1997dec7 --- /dev/null +++ b/src/ClaudeDo.Installer/Checks/Interfaces/IEnvironmentCheck.cs @@ -0,0 +1,10 @@ +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Checks; + +public interface IEnvironmentCheck +{ + string Id { get; } + CheckSeverity Severity { get; } + Task RunAsync(InstallContext ctx, CancellationToken ct); +} diff --git a/tests/ClaudeDo.Installer.Tests/EnvironmentCheckServiceTests.cs b/tests/ClaudeDo.Installer.Tests/EnvironmentCheckServiceTests.cs new file mode 100644 index 00000000..3033b39d --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/EnvironmentCheckServiceTests.cs @@ -0,0 +1,122 @@ +using ClaudeDo.Installer.Checks; +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Tests; + +public sealed class EnvironmentCheckServiceTests +{ + private sealed class FakeCheck : IEnvironmentCheck + { + private readonly Func>? _run; + + public FakeCheck(string id, CheckSeverity severity, Func>? run = null) + { + Id = id; + Severity = severity; + _run = run; + } + + public string Id { get; } + public CheckSeverity Severity { get; } + + public Task RunAsync(InstallContext ctx, CancellationToken ct) => + _run is null + ? Task.FromResult(CheckResult.Ok(Id, Severity, "title", "ok")) + : _run(ctx, ct); + } + + private sealed class ThrowingCheck : IEnvironmentCheck + { + public string Id => "throwing"; + public CheckSeverity Severity => CheckSeverity.Error; + + public Task RunAsync(InstallContext ctx, CancellationToken ct) => + throw new InvalidOperationException("boom"); + } + + [Fact] + public async Task RunAllAsync_PreservesInputOrder_RegardlessOfCompletionOrder() + { + var slow = new FakeCheck("slow", CheckSeverity.Warning, async (_, ct) => + { + await Task.Delay(50, ct); + return CheckResult.Ok("slow", CheckSeverity.Warning, "title", "ok"); + }); + var fast = new FakeCheck("fast", CheckSeverity.Warning); + var service = new EnvironmentCheckService(new IEnvironmentCheck[] { slow, fast }); + + var report = await service.RunAllAsync(new InstallContext(), CancellationToken.None); + + Assert.Equal(new[] { "slow", "fast" }, report.Results.Select(r => r.Id)); + } + + [Fact] + public async Task HasBlockingError_True_WhenErrorAndFailed() + { + var check = new FakeCheck("id", CheckSeverity.Error, (_, _) => + Task.FromResult(CheckResult.Fail("id", CheckSeverity.Error, "title", "bad"))); + var service = new EnvironmentCheckService(new IEnvironmentCheck[] { check }); + + var report = await service.RunAllAsync(new InstallContext(), CancellationToken.None); + + Assert.True(report.HasBlockingError); + } + + [Fact] + public async Task HasBlockingError_False_WhenWarningAndFailed() + { + var check = new FakeCheck("id", CheckSeverity.Warning, (_, _) => + Task.FromResult(CheckResult.Fail("id", CheckSeverity.Warning, "title", "bad"))); + var service = new EnvironmentCheckService(new IEnvironmentCheck[] { check }); + + var report = await service.RunAllAsync(new InstallContext(), CancellationToken.None); + + Assert.False(report.HasBlockingError); + } + + [Fact] + public async Task HasBlockingError_False_WhenErrorAndUnknown() + { + var check = new FakeCheck("id", CheckSeverity.Error, (_, _) => + Task.FromResult(CheckResult.Unknown("id", CheckSeverity.Error, "title", "dunno"))); + var service = new EnvironmentCheckService(new IEnvironmentCheck[] { check }); + + var report = await service.RunAllAsync(new InstallContext(), CancellationToken.None); + + Assert.False(report.HasBlockingError); + } + + [Fact] + public async Task RunAllAsync_ThrowingCheck_BecomesUnknown_OthersStillRun_ServiceDoesNotThrow() + { + var throwing = new ThrowingCheck(); + var ok = new FakeCheck("ok", CheckSeverity.Warning); + var service = new EnvironmentCheckService(new IEnvironmentCheck[] { throwing, ok }); + + var report = await service.RunAllAsync(new InstallContext(), CancellationToken.None); + + var throwingResult = report.Results[0]; + Assert.Equal(CheckStatus.Unknown, throwingResult.Status); + Assert.Contains("boom", throwingResult.Detail); + + var okResult = report.Results[1]; + Assert.Equal(CheckStatus.Ok, okResult.Status); + } + + [Fact] + public async Task RunAllAsync_PassesCancellationToken_ToChecks() + { + CancellationToken? seen = null; + var check = new FakeCheck("id", CheckSeverity.Warning, (_, ct) => + { + seen = ct; + return Task.FromResult(CheckResult.Ok("id", CheckSeverity.Warning, "title", "ok")); + }); + var service = new EnvironmentCheckService(new IEnvironmentCheck[] { check }); + using var cts = new CancellationTokenSource(); + + await service.RunAllAsync(new InstallContext(), cts.Token); + + Assert.Equal(cts.Token, seen); + } +}