From 5b98a5ae0c4f76aedb05e22cd5aa53e54c57769f Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Fri, 21 Aug 2026 13:30:02 +0000 Subject: [PATCH 1/2] chore: adopt the revised .editorconfig and enable EnforceCodeStyleInBuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NextIteration.Standards §5.2 and §1.2.1, the latter now a MUST. Closes the `1.2 Directory.Build.props` and `5.2 .editorconfig canon` audit rows. The canonical .editorconfig is a deliberate allow-list of gated style rules — no blanket dotnet_analyzer_diagnostic.severity — so a rule a future SDK ships never auto-gates the build. Directory.Build.props now equals the template in non-comment content, EnforceCodeStyleInBuild included. Bringing the code green was mechanical and behaviour-preserving, 92 sites: - IDE0011 braces on single-statement ifs (64) - IDE0300/IDE0301/IDE0028 collection expressions - IDE0007 var, IDE0022 expression bodies, IDE0270 null check, IDE0055 format - IDE0005 five genuinely unnecessary usings in src/ Applied with `dotnet format style`, plus the collection-expression sites its fixer does not handle. 392 tests (196 × net8.0/net10.0) pass unchanged; build stays at zero warnings. IDE0005 is suppressed in the test project (§2.7) and the demo, which is the same shape — not shipped, GenerateDocumentationFile=false, so IDE0005 would hard-error demanding the doc file rather than run. It still gates src/. Co-Authored-By: Claude Opus 5 --- .editorconfig | 232 +++++++++++------- CHANGELOG.md | 3 + Directory.Build.props | 20 +- ...tion.SpectreConsole.SelfUpdate.Demo.csproj | 10 + .../CommandConfiguratorExtensions.cs | 1 + .../Commands/UpdateCommand.cs | 6 +- .../Pipeline/SelfUpdater.cs | 7 +- .../Pipeline/UpdateCacheFile.cs | 6 +- .../Pipeline/UpdateChecker.cs | 119 +++++++-- .../Pipeline/UpdateInstaller.cs | 69 ++++-- .../Resolution/DefaultAssetResolver.cs | 60 ++++- .../RuntimeIdentifier.cs | 18 +- .../SelfUpdaterOptions.cs | 11 +- .../Sources/GhCliReleaseSource.cs | 45 ++-- .../Sources/HttpGitHubReleaseSource.cs | 27 +- .../Sources/HttpManifestSource.cs | 15 +- .../UpdateBanner.cs | 5 +- .../Verification/Sha256ChecksumVerifier.cs | 20 +- .../Verification/Sha256SumsManifest.cs | 28 ++- .../CommandConfiguratorExtensionsTests.cs | 6 +- .../Commands/UpdateCommandTests.cs | 4 +- .../Infrastructure/FakeHttpHandler.cs | 8 +- .../Infrastructure/RecordingProgress.cs | 4 +- .../Infrastructure/TempDir.cs | 2 +- .../Infrastructure/TestRegistrar.cs | 5 +- ...ion.SpectreConsole.SelfUpdate.Tests.csproj | 8 +- .../Pipeline/ArchiveExtractorTests.cs | 2 +- .../Pipeline/InstallLockTests.cs | 5 +- .../Pipeline/UpdateCheckerTests.cs | 4 +- .../Pipeline/UpdateCheckerVersionTests.cs | 30 +-- .../Pipeline/UpdateInstallerTests.cs | 58 ++--- .../Resolution/DefaultAssetResolverTests.cs | 5 +- .../RuntimeIdentifierTests.cs | 4 +- .../Sources/GhCliReleaseSourceTests.cs | 9 +- .../UpdateCleanupTests.cs | 15 +- .../Sha256ChecksumVerifierTests.cs | 17 +- .../Verification/Sha256SumsManifestTests.cs | 5 +- 37 files changed, 569 insertions(+), 324 deletions(-) diff --git a/.editorconfig b/.editorconfig index bd0de72..dcd945c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,8 +1,19 @@ root = true -# ------------------------------- -# General -# ------------------------------- +# ============================================================ +# Canonical .editorconfig for the NextIteration estate. +# +# Copied verbatim into every governed repo (STANDARD.md §5.2). +# Posture: a DELIBERATE ALLOW-LIST of style gates — there is no +# blanket `dotnet_analyzer_diagnostic.severity`, so a style rule a +# future SDK ships never auto-gates the build. Every rule that IS +# gated below (`:warning`) is a hard build failure under +# `TreatWarningsAsErrors`; that is intentional — the build is what +# forces the code into the ordained style. Code-quality (CA) rules +# keep their `AnalysisLevel=latest` defaults. +# ============================================================ + +# ---------- All files ---------- [*] charset = utf-8 end_of_line = lf @@ -11,99 +22,136 @@ indent_style = space indent_size = 4 trim_trailing_whitespace = true -# ------------------------------- -# C# files -# ------------------------------- -[*.cs] - -indent_size = 4 - -# New lines & braces -csharp_new_line_before_open_brace = all -csharp_prefer_braces = true:warning - -# Using directives -dotnet_sort_system_directives_first = true -dotnet_separate_import_directive_groups = true - -# var usage (Spectre-style: pragmatic) -csharp_style_var_for_built_in_types = true:suggestion -csharp_style_var_when_type_is_apparent = true:suggestion -csharp_style_var_elsewhere = false:suggestion - -# Expression-bodied members (used where clean) -csharp_style_expression_bodied_methods = when_on_single_line:suggestion -csharp_style_expression_bodied_constructors = false:suggestion -csharp_style_expression_bodied_operators = when_on_single_line:suggestion -csharp_style_expression_bodied_properties = when_on_single_line:suggestion - -# Pattern matching / modern C# -csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion -csharp_style_pattern_matching_over_as_with_null_check = true:suggestion - -# Nullability helpers -dotnet_style_null_propagation = true:suggestion -dotnet_style_coalesce_expression = true:suggestion - -# Readonly fields -dotnet_style_readonly_field = true:suggestion - -# ------------------------------- -# Naming -# ------------------------------- +[*.{csproj,props,targets}] +indent_size = 2 -# Private fields: _camelCase -dotnet_naming_rule.private_fields_should_be_camel_case.severity = suggestion -dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields -dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_with_underscore +[*.{json,yml,yaml}] +indent_size = 2 -dotnet_naming_symbols.private_fields.applicable_kinds = field -dotnet_naming_symbols.private_fields.applicable_accessibilities = private -# A const IS a field, so without this the rule demands `_nonceSize` for -# `private const int NonceSize` — PascalCase constants are correct .NET style and -# the codebase uses them throughout. Restricting the rule to instance fields keeps -# it aimed at what it was written for. Found when EnforceCodeStyleInBuild surfaced -# 76 IDE1006 violations, every one of them a constant. -dotnet_naming_symbols.private_fields.required_modifiers = - -dotnet_naming_style.camel_case_with_underscore.capitalization = camel_case -dotnet_naming_style.camel_case_with_underscore.required_prefix = _ - -# Interfaces: IMyInterface -dotnet_naming_rule.interfaces_should_start_with_i.severity = suggestion -dotnet_naming_rule.interfaces_should_start_with_i.symbols = interfaces -dotnet_naming_rule.interfaces_should_start_with_i.style = interface_prefix +# Trailing whitespace is a hard line break in Markdown +[*.md] +trim_trailing_whitespace = false -dotnet_naming_symbols.interfaces.applicable_kinds = interface +# ---------- C# ---------- +[*.cs] +indent_size = 4 +# Braces & new lines +csharp_new_line_before_open_brace = all +csharp_prefer_braces = true:warning # braces always (IDE0011) + +# Namespaces — block-scoped estate-wide (IDE0160) +csharp_style_namespace_declarations = block_scoped:warning + +# using directives +csharp_using_directive_placement = outside_namespace:warning # IDE0065 +dotnet_sort_system_directives_first = true # feeds IDE0055 (gated below) +dotnet_separate_import_directive_groups = true # feeds IDE0055 — matches estate style + +# 'this.' qualification — never used in this estate +dotnet_style_qualification_for_field = false:warning # IDE0003 +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning + +# Accessibility — always explicit +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning # IDE0040 + +# var — ordained: var everywhere it is legal (IDE0007) +csharp_style_var_for_built_in_types = true:warning +csharp_style_var_when_type_is_apparent = true:warning +csharp_style_var_elsewhere = true:warning + +# Expression-bodied members — single-line only; block-bodied constructors (IDE0021-0027) +csharp_style_expression_bodied_methods = when_on_single_line:warning +csharp_style_expression_bodied_constructors = false:warning +csharp_style_expression_bodied_operators = when_on_single_line:warning +csharp_style_expression_bodied_properties = when_on_single_line:warning + +# Pattern matching / null handling (IDE0019/0020/0029/0030/0031) +csharp_style_pattern_matching_over_is_with_cast_check = true:warning +csharp_style_pattern_matching_over_as_with_null_check = true:warning +dotnet_style_null_propagation = true:warning +dotnet_style_coalesce_expression = true:warning + +# readonly fields (IDE0044) — only flags never-reassigned fields, so genuinely mutable state is safe +dotnet_style_readonly_field = true:warning + +# Modern syntax — gated toward the modern form +dotnet_style_prefer_collection_expression = when_types_loosely_match:warning # IDE0300+ +csharp_style_implicit_object_creation_when_type_is_apparent = true:warning # IDE0090 + +# Primary constructors — NOT forced. IDE0290 is one-directional (it can only push toward primary +# constructors), and forcing them onto service classes with real initialisation is a downgrade. +# Advisory only; existing class primary constructors are left as they are. +csharp_style_prefer_primary_constructors = false:suggestion # IDE0290 + +# Unused expression/assignment values — never nudge toward `_ =` discards (IDE0058/IDE0059). +# House rule: no discard solely to swallow a return value (see CLAUDE.md for the carve-outs). +csharp_style_unused_value_expression_statement_preference = discard_variable:silent +csharp_style_unused_value_assignment_preference = discard_variable:silent + +# ---------- Naming (gated) ---------- +# Matches estate reality: _camelCase private fields (incl. static readonly), PascalCase const +# fields, PascalCase types/members, I-prefixed interfaces, T-prefixed type parameters, camelCase +# locals/parameters. `const_fields` is declared before `private_fields`: a const is a field, so +# both specs match it and precedence decides — const → PascalCase must win. + +# Styles +dotnet_naming_style.pascal_case.capitalization = pascal_case +dotnet_naming_style.camel_case_underscore.required_prefix = _ +dotnet_naming_style.camel_case_underscore.capitalization = camel_case +dotnet_naming_style.camel_case_plain.capitalization = camel_case dotnet_naming_style.interface_prefix.required_prefix = I dotnet_naming_style.interface_prefix.capitalization = pascal_case +dotnet_naming_style.type_param_prefix.required_prefix = T +dotnet_naming_style.type_param_prefix.capitalization = pascal_case -# ------------------------------- -# Analyzers -# ------------------------------- - -# Keep warnings visible but not painful -dotnet_analyzer_diagnostic.severity = warning - -# Unused usings -dotnet_diagnostic.IDE0005.severity = warning - -# Simplification -dotnet_diagnostic.IDE0007.severity = suggestion -dotnet_diagnostic.IDE0008.severity = suggestion - -# Documentation (Spectre.Console is pragmatic here) -dotnet_diagnostic.CS1591.severity = silent - -# ------------------------------- -# JSON / YAML -# ------------------------------- -[*.json] -indent_size = 2 - -[*.yml] -indent_size = 2 - -[*.yaml] -indent_size = 2 \ No newline at end of file +# Symbols +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter +dotnet_naming_symbols.types.applicable_kinds = class, struct, enum, delegate +dotnet_naming_symbols.non_field_members.applicable_kinds = property, method, event +dotnet_naming_symbols.const_fields.applicable_kinds = field +dotnet_naming_symbols.const_fields.required_modifiers = const +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, private_protected, internal, protected_internal +dotnet_naming_symbols.locals_and_params.applicable_kinds = parameter, local + +# Rules (all warning = gated) +dotnet_naming_rule.interfaces_i.severity = warning +dotnet_naming_rule.interfaces_i.symbols = interfaces +dotnet_naming_rule.interfaces_i.style = interface_prefix + +dotnet_naming_rule.type_params_t.severity = warning +dotnet_naming_rule.type_params_t.symbols = type_parameters +dotnet_naming_rule.type_params_t.style = type_param_prefix + +dotnet_naming_rule.types_pascal.severity = warning +dotnet_naming_rule.types_pascal.symbols = types +dotnet_naming_rule.types_pascal.style = pascal_case + +dotnet_naming_rule.members_pascal.severity = warning +dotnet_naming_rule.members_pascal.symbols = non_field_members +dotnet_naming_rule.members_pascal.style = pascal_case + +dotnet_naming_rule.const_pascal.severity = warning +dotnet_naming_rule.const_pascal.symbols = const_fields +dotnet_naming_rule.const_pascal.style = pascal_case + +dotnet_naming_rule.private_underscore.severity = warning +dotnet_naming_rule.private_underscore.symbols = private_fields +dotnet_naming_rule.private_underscore.style = camel_case_underscore + +dotnet_naming_rule.locals_camel.severity = warning +dotnet_naming_rule.locals_camel.symbols = locals_and_params +dotnet_naming_rule.locals_camel.style = camel_case_plain + +# ---------- Option-less / compiler severities ---------- +dotnet_diagnostic.IDE0005.severity = warning # unnecessary usings (needs GenerateDocumentationFile — set by §1.6) +dotnet_diagnostic.IDE0055.severity = warning # formatting: whitespace, using sort/groups +dotnet_diagnostic.CS1591.severity = warning # public members MUST carry XML docs (STANDARD §1.6, CLAUDE.md non-negotiable) + +# ---------- Deliberate exemptions (advisory, never gate) ---------- +dotnet_diagnostic.IDE0046.severity = suggestion # convert to conditional expression — nested ternaries hurt readability +dotnet_diagnostic.IDE0058.severity = suggestion # unused expression value — see the discard house rule above diff --git a/CHANGELOG.md b/CHANGELOG.md index 9636530..61689e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Adopted the revised canonical `.editorconfig` and enabled `EnforceCodeStyleInBuild`** (NextIteration.Standards §5.2, §1.2.1 — the latter now a `MUST`). The canonical file is a deliberate allow-list of gated style rules rather than a blanket `dotnet_analyzer_diagnostic.severity`, so a style rule a future SDK ships never auto-gates the build. With the flag on, the gated rules fail the build under `TreatWarningsAsErrors` instead of merely showing in the IDE. Bringing the code green was a mechanical, behaviour-preserving reformat of 92 sites — braces on all single-statement `if`s (IDE0011, 64 of them), collection expressions (IDE0300/IDE0301/IDE0028), `var` usage, two expression-bodied members, one simplified null check, and five unnecessary usings — applied with `dotnet format` plus the collection-expression sites it cannot fix automatically. All 392 tests (196 × `net8.0`/`net10.0`) pass unchanged, and the build stays at zero warnings. +- **`IDE0005` suppressed in the non-shipping projects.** It only runs in-build when `GenerateDocumentationFile` is `true`, which both the test and demo projects set to `false` — sample and fixture code carries no XML docs, and `TreatWarningsAsErrors` would fail the build over every missing one. With `EnforceCodeStyleInBuild` on it hard-errors demanding the doc file be enabled instead. NextIteration.Standards §2.7 mandates this for the test project; the demo is the same shape (not shipped, so not public surface) and needs the same opt-out. `IDE0005` still gates the shipping project, where the doc file is on — and it caught five real unnecessary usings there. + - **`release.yml` folded into `ci.yml`.** Publishing now happens in the same workflow run as the build, so the `publish` job pushes the artifact this run's `build` job produced — the exact bytes the gate tested. The old tag-triggered `release.yml` rebuilt from the tag and published an artifact no gate had ever seen. It also globbed `*.nupkg` when uploading, so the `.snupkg` was built and then silently never published; the glob is now `*nupkg` and symbols ship. Repointing the nuget.org Trusted Publishing policy from `release.yml` to `ci.yml` was part of the same change, because the policy is bound to a workflow filename. - **CI now has a single aggregating gate job, `ci`, and it is the only required status check.** `build` and `test` were required directly before, which couples the branch ruleset to the matrix: `test`'s check names carry the matrix values, so adding or dropping a platform broke protection. The gate declares `needs: [build, test]` with `if: always()` and fails on any upstream result that is not success — including `skipped`, which branch protection would otherwise read as satisfied. - **Every workflow declares `concurrency`, explicit `permissions`, and per-job `timeout-minutes`.** Superseded pushes cancel instead of stacking up, except on tags — a half-cancelled release can leave an incomplete package set on nuget.org. NuGet restore is cached on `~/.nuget/packages`. diff --git a/Directory.Build.props b/Directory.Build.props index 65a7154..1480ced 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,15 +1,30 @@ + enable enable en latest + true true true true @@ -24,4 +39,5 @@ Stuart Meeks Next Iteration + diff --git a/demo/NextIteration.SpectreConsole.SelfUpdate.Demo/NextIteration.SpectreConsole.SelfUpdate.Demo.csproj b/demo/NextIteration.SpectreConsole.SelfUpdate.Demo/NextIteration.SpectreConsole.SelfUpdate.Demo.csproj index 25222a7..11f042b 100644 --- a/demo/NextIteration.SpectreConsole.SelfUpdate.Demo/NextIteration.SpectreConsole.SelfUpdate.Demo.csproj +++ b/demo/NextIteration.SpectreConsole.SelfUpdate.Demo/NextIteration.SpectreConsole.SelfUpdate.Demo.csproj @@ -12,6 +12,16 @@ the test project (STANDARD.md 2.7). --> false + + $(NoWarn);IDE0005 diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/CommandConfiguratorExtensions.cs b/src/NextIteration.SpectreConsole.SelfUpdate/CommandConfiguratorExtensions.cs index e35c059..da52b75 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/CommandConfiguratorExtensions.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/CommandConfiguratorExtensions.cs @@ -1,4 +1,5 @@ using NextIteration.SpectreConsole.SelfUpdate.Commands; + using Spectre.Console.Cli; namespace NextIteration.SpectreConsole.SelfUpdate diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs index bdea55c..ff2e2a0 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Commands/UpdateCommand.cs @@ -194,12 +194,10 @@ private Task PromptForConflictAsync(UpdateConflict con return Task.FromResult(keep ? UpdateConflictResolution.KeepExisting : UpdateConflictResolution.UseNew); } - private static bool IsUpdateAvailable(string current, string latestTag) - { + private static bool IsUpdateAvailable(string current, string latestTag) => // Defer to the same comparator the checker uses so behaviour is // identical between the cached probe and this fresh one. - return Pipeline.UpdateChecker.IsNewer(current, latestTag); - } + Pipeline.UpdateChecker.IsNewer(current, latestTag); private static string StageLabel(UpdateStage stage) => stage switch { diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs index cfcb59c..68e74cb 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/SelfUpdater.cs @@ -51,12 +51,9 @@ public Task InstallAsync( public async Task InstallAsync(IProgress? progress = null, CancellationToken ct = default) { - var release = await _source.GetLatestAsync(_options.Channel, ct).ConfigureAwait(false); - if (release is null) - { - throw new UpdateException( + var release = await _source.GetLatestAsync(_options.Channel, ct).ConfigureAwait(false) + ?? throw new UpdateException( "No release is available from the configured update source. The source either returned null or is currently unreachable."); - } await _installer.InstallAsync(release, progress, onConflict: null, ct).ConfigureAwait(false); } } diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateCacheFile.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateCacheFile.cs index 209e7d4..2263e09 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateCacheFile.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateCacheFile.cs @@ -22,7 +22,11 @@ internal static class UpdateCacheFile { try { - if (!File.Exists(path)) return null; + if (!File.Exists(path)) + { + return null; + } + var json = File.ReadAllText(path); return JsonSerializer.Deserialize(json, JsonOpts); } diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs index 64c2234..7491f0c 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateChecker.cs @@ -50,11 +50,21 @@ internal UpdateChecker( public async Task CheckAsync(bool? includePrereleasesOverride, CancellationToken ct = default) { - if (IsOptOutSet()) return null; + if (IsOptOutSet()) + { + return null; + } var current = GetCurrentVersion(); - if (current is null) return null; - if (IsVersionSkipped(current)) return null; + if (current is null) + { + return null; + } + + if (IsVersionSkipped(current)) + { + return null; + } var effectivePrerelease = includePrereleasesOverride ?? _options.IncludePrereleases; var cachePath = ResolveCacheFilePath(); @@ -70,7 +80,10 @@ internal UpdateChecker( linked.CancelAfter(_options.CheckTimeout); var release = await _source.GetLatestAsync(_options.Channel, includePrereleasesOverride, linked.Token).ConfigureAwait(false); - if (release is null) return null; + if (release is null) + { + return null; + } UpdateCacheFile.TryWrite(cachePath, new UpdateCacheEntry( CheckedAt: _utcNow(), @@ -94,7 +107,11 @@ internal UpdateChecker( public string? GetCurrentVersion() { var v = _currentVersionResolver(); - if (string.IsNullOrWhiteSpace(v)) return null; + if (string.IsNullOrWhiteSpace(v)) + { + return null; + } + return StripBuildMetadata(v); } @@ -106,7 +123,11 @@ internal bool IsVersionSkipped(string version) => internal bool IsOptOutSet() { var name = ResolveSkipEnvVarName(); - if (string.IsNullOrWhiteSpace(name)) return false; + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + return string.Equals(_envResolver(name), "1", StringComparison.Ordinal); } @@ -132,12 +153,23 @@ internal bool IsCacheFresh(UpdateCacheEntry? entry) => internal bool IsCacheFresh(UpdateCacheEntry? entry, bool effectivePrerelease) { - if (entry is null) return false; - if (!string.Equals(entry.Channel, _options.Channel, StringComparison.Ordinal)) return false; + if (entry is null) + { + return false; + } + + if (!string.Equals(entry.Channel, _options.Channel, StringComparison.Ordinal)) + { + return false; + } // Pre-0.1.4 cache entries have no IncludePrereleases field — they // were written before the override existed, so they always // reflect a non-prerelease answer. - if ((entry.IncludePrereleases ?? false) != effectivePrerelease) return false; + if ((entry.IncludePrereleases ?? false) != effectivePrerelease) + { + return false; + } + return (_utcNow() - entry.CheckedAt) < _options.CacheTtl; } @@ -159,8 +191,15 @@ internal static bool IsNewer(string current, string latestTag) return false; } - if (lv > cv) return true; - if (lv < cv) return false; + if (lv > cv) + { + return true; + } + + if (lv < cv) + { + return false; + } // Numeric versions equal — compare prereleases. Semver: a release // without a prerelease is newer than one with a prerelease at the @@ -172,9 +211,20 @@ internal static bool IsNewer(string current, string latestTag) internal static int ComparePrerelease(string? current, string? latest) { - if (current is null && latest is null) return 0; - if (current is null) return 1; // no-prerelease > prerelease, so current is newer - if (latest is null) return -1; // current has prerelease, latest does not → current is older + if (current is null && latest is null) + { + return 0; + } + + if (current is null) + { + return 1; // no-prerelease > prerelease, so current is newer + } + + if (latest is null) + { + return -1; // current has prerelease, latest does not → current is older + } // Semver §11: compare dot-separated identifiers left to right. // Numeric identifiers compare numerically; numeric is always lower @@ -187,7 +237,10 @@ internal static int ComparePrerelease(string? current, string? latest) for (var i = 0; i < shared; i++) { var cmp = ComparePrereleaseIdentifier(cParts[i], lParts[i]); - if (cmp != 0) return cmp; + if (cmp != 0) + { + return cmp; + } } return cParts.Length.CompareTo(lParts.Length); } @@ -197,22 +250,42 @@ private static int ComparePrereleaseIdentifier(string current, string latest) var cNumeric = long.TryParse(current, out var cn); var lNumeric = long.TryParse(latest, out var ln); - if (cNumeric && lNumeric) return cn.CompareTo(ln); - if (cNumeric) return -1; // numeric identifiers rank lower than alphanumeric - if (lNumeric) return 1; + if (cNumeric && lNumeric) + { + return cn.CompareTo(ln); + } + + if (cNumeric) + { + return -1; // numeric identifiers rank lower than alphanumeric + } + + if (lNumeric) + { + return 1; + } + return string.CompareOrdinal(current, latest); } internal static (string Numeric, string? Prerelease) SplitNumericPrerelease(string version) { var dash = version.IndexOf('-', StringComparison.Ordinal); - if (dash < 0) return (version, null); + if (dash < 0) + { + return (version, null); + } + return (version[..dash], version[(dash + 1)..]); } internal static string StripLeadingV(string version) { - if (string.IsNullOrEmpty(version)) return string.Empty; + if (string.IsNullOrEmpty(version)) + { + return string.Empty; + } + return version[0] is 'v' or 'V' ? version[1..] : version; } @@ -224,7 +297,11 @@ internal static string StripBuildMetadata(string version) internal static string ComputeDefaultSkipEnvVarName(string appName) { - if (string.IsNullOrWhiteSpace(appName)) return string.Empty; + if (string.IsNullOrWhiteSpace(appName)) + { + return string.Empty; + } + var sb = new StringBuilder(appName.Length + "_SKIP_UPDATE_CHECK".Length); foreach (var c in appName) { diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs index 1c7cced..ab6fc51 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Pipeline/UpdateInstaller.cs @@ -1,5 +1,3 @@ -using System.Linq; - namespace NextIteration.SpectreConsole.SelfUpdate.Pipeline { /// @@ -165,7 +163,11 @@ internal async Task DownloadAssetAsync(ReleaseAsset asset, string destinationPat await using var fileStream = File.Create(destinationPath); var downloadProgress = new Progress(dp => { - if (progress is null) return; + if (progress is null) + { + return; + } + if (dp.TotalBytes is { } total && total > 0) { progress.Report(new UpdateProgressEvent( @@ -193,7 +195,7 @@ internal static string ResolveSourceDirectory(string extractedDirectory) // don't need preserve-path or conflict-resolution semantics. // Equivalent to SwapAsync(..., preservePaths: empty, onConflict: null). internal static void Swap(string sourceDirectory, string installDirectory, string oldDirectory) => - SwapAsync(sourceDirectory, installDirectory, oldDirectory, Array.Empty(), onConflict: null, CancellationToken.None) + SwapAsync(sourceDirectory, installDirectory, oldDirectory, [], onConflict: null, CancellationToken.None) .GetAwaiter().GetResult(); internal static async Task SwapAsync( @@ -220,8 +222,15 @@ internal static async Task SwapAsync( foreach (var entry in Directory.EnumerateFileSystemEntries(installDirectory)) { var name = Path.GetFileName(entry); - if (IsMaintenanceEntry(name)) continue; - if (IsPreserved(name, preservePaths)) continue; + if (IsMaintenanceEntry(name)) + { + continue; + } + + if (IsPreserved(name, preservePaths)) + { + continue; + } var dest = Path.Combine(oldDirectory, name); if (File.Exists(entry)) @@ -274,8 +283,15 @@ internal static async Task SwapAsync( // rollback restores the right thing on failure. var oldDest = Path.Combine(oldDirectory, name); TryDeleteEntry(oldDest); - if (File.Exists(dest)) File.Move(dest, oldDest); - else if (Directory.Exists(dest)) Directory.Move(dest, oldDest); + if (File.Exists(dest)) + { + File.Move(dest, oldDest); + } + else if (Directory.Exists(dest)) + { + Directory.Move(dest, oldDest); + } + movedNames.Add(name); } // else: a new release introduces a path that the @@ -325,17 +341,28 @@ private static async Task ResolveConflictAsync( internal static bool IsPreserved(string name, IReadOnlyList preservePaths) { - if (preservePaths.Count == 0) return false; + if (preservePaths.Count == 0) + { + return false; + } + foreach (var pattern in preservePaths) { - if (string.IsNullOrWhiteSpace(pattern)) continue; + if (string.IsNullOrWhiteSpace(pattern)) + { + continue; + } // Take the part of the pattern before the first slash — // this lets `data/**`, `data/seed.json`, and bare `data` // all match the top-level entry `data`. Nested-only // preservation (e.g. preserve only `data/seed.json` but // not the rest of `data/`) is out of scope for v0.1.x. var head = TopLevelSegment(pattern); - if (head.Length == 0) continue; + if (head.Length == 0) + { + continue; + } + if (System.IO.Enumeration.FileSystemName.MatchesSimpleExpression(head, name, ignoreCase: true)) { return true; @@ -349,7 +376,10 @@ private static ReadOnlySpan TopLevelSegment(string pattern) ReadOnlySpan span = pattern; for (var i = 0; i < span.Length; i++) { - if (span[i] == '/' || span[i] == '\\') return span[..i]; + if (span[i] == '/' || span[i] == '\\') + { + return span[..i]; + } } return span; } @@ -389,8 +419,14 @@ private static void TryDeleteEntry(string path) { try { - if (File.Exists(path)) File.Delete(path); - else if (Directory.Exists(path)) DeleteDirectoryRobustly(path); + if (File.Exists(path)) + { + File.Delete(path); + } + else if (Directory.Exists(path)) + { + DeleteDirectoryRobustly(path); + } } catch { @@ -417,7 +453,10 @@ internal static void DeleteDirectoryRobustly( Action? deleter = null, Action? sleeper = null) { - if (!Directory.Exists(path)) return; + if (!Directory.Exists(path)) + { + return; + } deleter ??= Directory.Delete; sleeper ??= Thread.Sleep; diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Resolution/DefaultAssetResolver.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Resolution/DefaultAssetResolver.cs index 66fe80f..ca7eb5c 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Resolution/DefaultAssetResolver.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Resolution/DefaultAssetResolver.cs @@ -23,7 +23,7 @@ namespace NextIteration.SpectreConsole.SelfUpdate.Resolution /// public sealed class DefaultAssetResolver : IAssetResolver { - private static readonly string[] ArchiveExtensions = { ".tar.gz", ".tgz", ".zip" }; + private static readonly string[] ArchiveExtensions = [".tar.gz", ".tgz", ".zip"]; private readonly string _appName; @@ -46,7 +46,10 @@ public DefaultAssetResolver(string appName) foreach (var rid in CandidateRids(runtimeIdentifier)) { var match = ResolveForRid(release, rid); - if (match is not null) return match; + if (match is not null) + { + return match; + } } return null; } @@ -58,19 +61,31 @@ public DefaultAssetResolver(string appName) // 1. {app}-v{ver}-{rid}.ext var match = MatchExact(release, $"{_appName}-{versionWithV}-{rid}"); - if (match is not null) return match; + if (match is not null) + { + return match; + } // 2. {app}-{ver}-{rid}.ext (in case tag is unprefixed) match = MatchExact(release, $"{_appName}-{versionNumeric}-{rid}"); - if (match is not null) return match; + if (match is not null) + { + return match; + } // 3. {app}-{rid}.ext (no version segment) match = MatchExact(release, $"{_appName}-{rid}"); - if (match is not null) return match; + if (match is not null) + { + return match; + } // 4. {app}…-{rid}.ext (loose: starts with app, ends with -rid+ext) match = MatchPrefixSuffix(release, $"{_appName}-", $"-{rid}"); - if (match is not null) return match; + if (match is not null) + { + return match; + } // 5. *…-{rid}.ext (RID-only — last resort, may be ambiguous) match = MatchSuffixOnly(release, $"-{rid}"); @@ -97,7 +112,10 @@ private static IEnumerable CandidateRids(string runtimeIdentifier) { foreach (var ext in ArchiveExtensions) { - if (NameEquals(asset.Name, stem + ext)) return asset; + if (NameEquals(asset.Name, stem + ext)) + { + return asset; + } } } return null; @@ -107,8 +125,16 @@ private static IEnumerable CandidateRids(string runtimeIdentifier) { foreach (var asset in release.Assets) { - if (!asset.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue; - if (!EndsWithRidAndArchive(asset.Name, ridSuffix)) continue; + if (!asset.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!EndsWithRidAndArchive(asset.Name, ridSuffix)) + { + continue; + } + return asset; } return null; @@ -118,7 +144,10 @@ private static IEnumerable CandidateRids(string runtimeIdentifier) { foreach (var asset in release.Assets) { - if (EndsWithRidAndArchive(asset.Name, ridSuffix)) return asset; + if (EndsWithRidAndArchive(asset.Name, ridSuffix)) + { + return asset; + } } return null; } @@ -127,7 +156,10 @@ private static bool EndsWithRidAndArchive(string name, string ridSuffix) { foreach (var ext in ArchiveExtensions) { - if (name.EndsWith(ridSuffix + ext, StringComparison.OrdinalIgnoreCase)) return true; + if (name.EndsWith(ridSuffix + ext, StringComparison.OrdinalIgnoreCase)) + { + return true; + } } return false; } @@ -137,7 +169,11 @@ private static bool NameEquals(string actual, string expected) => private static string StripLeadingV(string tag) { - if (string.IsNullOrEmpty(tag)) return string.Empty; + if (string.IsNullOrEmpty(tag)) + { + return string.Empty; + } + return tag[0] is 'v' or 'V' ? tag[1..] : tag; } } diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/RuntimeIdentifier.cs b/src/NextIteration.SpectreConsole.SelfUpdate/RuntimeIdentifier.cs index a01f0f0..ca338fa 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/RuntimeIdentifier.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/RuntimeIdentifier.cs @@ -33,9 +33,21 @@ public static string Detect() private static string OsToken() { - if (OperatingSystem.IsWindows()) return "win"; - if (OperatingSystem.IsLinux()) return "linux"; - if (OperatingSystem.IsMacOS()) return "osx"; + if (OperatingSystem.IsWindows()) + { + return "win"; + } + + if (OperatingSystem.IsLinux()) + { + return "linux"; + } + + if (OperatingSystem.IsMacOS()) + { + return "osx"; + } + throw new PlatformNotSupportedException( $"Unsupported operating system: {RuntimeInformation.OSDescription}"); } diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs b/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs index 423a3cf..df0104b 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/SelfUpdaterOptions.cs @@ -138,7 +138,7 @@ public sealed class SelfUpdaterOptions /// the consumer wouldn't want to lose across an upgrade. /// /// - public IReadOnlyList PreservePaths { get; set; } = Array.Empty(); + public IReadOnlyList PreservePaths { get; set; } = []; // ---------- Source registration ---------- @@ -258,10 +258,7 @@ public void UseAssetResolver(Func resolver /// built-in SHA-256 check entirely. /// public void AddVerifier() - where TVerifier : class, IPackageVerifier - { - ExtraVerifierTypes.Add(typeof(TVerifier)); - } + where TVerifier : class, IPackageVerifier => ExtraVerifierTypes.Add(typeof(TVerifier)); /// /// Add an additional built by the @@ -289,8 +286,8 @@ public void AddVerifier(Func factory) internal Func? AssetResolverFactory { get; private set; } internal Func? AssetResolverFunc { get; private set; } - internal List ExtraVerifierTypes { get; } = new(); - internal List> ExtraVerifierFactories { get; } = new(); + internal List ExtraVerifierTypes { get; } = []; + internal List> ExtraVerifierFactories { get; } = []; } internal enum UpdateSourceKind diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs index 4b2f0f9..bd12edd 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/GhCliReleaseSource.cs @@ -1,4 +1,3 @@ -using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; @@ -86,12 +85,11 @@ internal GhCliReleaseSource( if (channel is null && !includePrereleases) { var stdout = await _runner( - new[] - { + [ "release", "view", "--json", "tagName,name,url,publishedAt,isDraft,isPrerelease,assets", "--repo", _repository, - }, + ], DefaultViewTimeout, ct).ConfigureAwait(false); var dto = JsonSerializer.Deserialize(stdout, JsonOpts); @@ -104,16 +102,15 @@ internal GhCliReleaseSource( // status. Only request fields used for filtering / sort here; // the full detail (incl. url, assets) is fetched per-tag below. var listJson = await _runner( - new[] - { + [ "release", "list", "--json", "tagName,publishedAt,isDraft,isPrerelease", "--limit", "30", "--repo", _repository, - }, + ], DefaultListTimeout, ct).ConfigureAwait(false); - var releases = JsonSerializer.Deserialize(listJson, JsonOpts) ?? Array.Empty(); + var releases = JsonSerializer.Deserialize(listJson, JsonOpts) ?? []; var match = releases .Where(r => !r.IsDraft) .Where(r => includePrereleases || !r.IsPrerelease) @@ -121,17 +118,19 @@ internal GhCliReleaseSource( || (r.TagName ?? string.Empty).Contains($"-{channel}", StringComparison.OrdinalIgnoreCase)) .OrderByDescending(r => r.PublishedAt) .FirstOrDefault(); - if (match is null) return null; + if (match is null) + { + return null; + } // `release list` doesn't return assets — fetch the matched // release in detail so DownloadAssetAsync has something to act on. var detailJson = await _runner( - new[] - { + [ "release", "view", match.TagName!, "--json", "tagName,name,url,publishedAt,isDraft,isPrerelease,assets", "--repo", _repository, - }, + ], DefaultViewTimeout, ct).ConfigureAwait(false); var detail = JsonSerializer.Deserialize(detailJson, JsonOpts); @@ -166,14 +165,13 @@ public async Task DownloadAssetAsync(ReleaseAsset asset, Stream destination, IPr progress?.Report(new DownloadProgress(0, asset.SizeBytes)); await _runner( - new[] - { + [ "release", "download", tag, "--repo", _repository, "--pattern", asset.Name, "--output", tempFile, "--clobber", - }, + ], DefaultDownloadTimeout, ct).ConfigureAwait(false); @@ -192,7 +190,13 @@ await _runner( private static void TryDelete(string path) { - try { if (File.Exists(path)) File.Delete(path); } + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } catch { // Best effort. @@ -201,10 +205,13 @@ private static void TryDelete(string path) private static RemoteRelease? Convert(GhReleaseDto dto, string? channel) { - if (string.IsNullOrWhiteSpace(dto.TagName)) return null; + if (string.IsNullOrWhiteSpace(dto.TagName)) + { + return null; + } var tag = dto.TagName!; - var assets = (dto.Assets ?? Array.Empty()) + var assets = (dto.Assets ?? []) .Where(a => !string.IsNullOrWhiteSpace(a.Name)) .Select(a => new ReleaseAsset( Name: a.Name!, @@ -214,7 +221,7 @@ private static void TryDelete(string path) Metadata: BuildAssetMetadata(tag))) .ToArray(); - Uri? notes = TryParseUri(dto.Url); + var notes = TryParseUri(dto.Url); var resolvedChannel = dto.IsPrerelease ? (channel ?? "prerelease") : channel; diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpGitHubReleaseSource.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpGitHubReleaseSource.cs index 2d81fc1..1a30a0c 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpGitHubReleaseSource.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpGitHubReleaseSource.cs @@ -1,4 +1,3 @@ -using System.Linq; using System.Net.Http.Headers; using System.Text.Json; using System.Text.Json.Serialization; @@ -102,7 +101,10 @@ internal HttpGitHubReleaseSource( } var releases = await GetJsonAsync(http, $"repos/{_repository}/releases?per_page=30", ct).ConfigureAwait(false); - if (releases is null || releases.Length == 0) return null; + if (releases is null || releases.Length == 0) + { + return null; + } var match = releases .Where(r => !r.Draft) @@ -171,7 +173,11 @@ private void ConfigureRequestHeaders(HttpClient http) private string? ResolveToken() { - if (!string.IsNullOrWhiteSpace(_explicitToken)) return _explicitToken; + if (!string.IsNullOrWhiteSpace(_explicitToken)) + { + return _explicitToken; + } + return Environment.GetEnvironmentVariable("GITHUB_TOKEN") ?? Environment.GetEnvironmentVariable("GH_TOKEN"); } @@ -180,7 +186,11 @@ private void ConfigureRequestHeaders(HttpClient http) { var url = new Uri(_apiBase, relativePath); using var resp = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); - if (resp.StatusCode == System.Net.HttpStatusCode.NotFound) return default; + if (resp.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return default; + } + resp.EnsureSuccessStatusCode(); await using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); return await JsonSerializer.DeserializeAsync(stream, JsonOpts, ct).ConfigureAwait(false); @@ -188,9 +198,12 @@ private void ConfigureRequestHeaders(HttpClient http) private static RemoteRelease? Convert(GitHubReleaseDto dto, string? channel) { - if (string.IsNullOrWhiteSpace(dto.TagName)) return null; + if (string.IsNullOrWhiteSpace(dto.TagName)) + { + return null; + } - var assets = (dto.Assets ?? Array.Empty()) + var assets = (dto.Assets ?? []) .Where(a => !string.IsNullOrWhiteSpace(a.Name) && !string.IsNullOrWhiteSpace(a.Url)) .Select(a => new ReleaseAsset( Name: a.Name!, @@ -200,7 +213,7 @@ private void ConfigureRequestHeaders(HttpClient http) Metadata: BuildAssetMetadata(a))) .ToArray(); - Uri? notes = string.IsNullOrWhiteSpace(dto.HtmlUrl) ? null : new Uri(dto.HtmlUrl); + var notes = string.IsNullOrWhiteSpace(dto.HtmlUrl) ? null : new Uri(dto.HtmlUrl); // Sources should report channel based on what they observed — // forward the caller's channel filter as the resolved channel diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpManifestSource.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpManifestSource.cs index 5a19a8e..36a02af 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpManifestSource.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Sources/HttpManifestSource.cs @@ -1,4 +1,3 @@ -using System.Linq; using System.Net.Http.Headers; using System.Text.Json; using System.Text.Json.Serialization; @@ -111,11 +110,17 @@ private static bool IsHttps(Uri uri) => { using var http = _httpClientFactory.CreateClient(); using var resp = await http.GetAsync(_manifestUrl, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); - if (!resp.IsSuccessStatusCode) return null; + if (!resp.IsSuccessStatusCode) + { + return null; + } await using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); var dto = await JsonSerializer.DeserializeAsync(stream, JsonOpts, ct).ConfigureAwait(false); - if (dto is null || string.IsNullOrWhiteSpace(dto.Tag)) return null; + if (dto is null || string.IsNullOrWhiteSpace(dto.Tag)) + { + return null; + } if (channel is not null && !string.IsNullOrWhiteSpace(dto.Channel) @@ -170,7 +175,7 @@ public async Task DownloadAssetAsync(ReleaseAsset asset, Stream destination, IPr private static RemoteRelease Convert(ManifestDto dto, string? channel) { - var assets = (dto.Assets ?? Array.Empty()) + var assets = (dto.Assets ?? []) .Where(a => !string.IsNullOrWhiteSpace(a.Name) && !string.IsNullOrWhiteSpace(a.Url)) .Select(a => new ReleaseAsset( Name: a.Name!, @@ -180,7 +185,7 @@ private static RemoteRelease Convert(ManifestDto dto, string? channel) Metadata: BuildMetadata(a))) .ToArray(); - Uri? notes = string.IsNullOrWhiteSpace(dto.ReleaseNotesUrl) + var notes = string.IsNullOrWhiteSpace(dto.ReleaseNotesUrl) ? null : new Uri(dto.ReleaseNotesUrl!, UriKind.Absolute); diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/UpdateBanner.cs b/src/NextIteration.SpectreConsole.SelfUpdate/UpdateBanner.cs index 13190b2..e9dc02d 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/UpdateBanner.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/UpdateBanner.cs @@ -68,7 +68,10 @@ public static void RenderIfAvailable( { return; } - if (info is null || !info.IsUpdateAvailable) return; + if (info is null || !info.IsUpdateAvailable) + { + return; + } var ansi = console ?? AnsiConsole.Console; if (info.ReleaseUrl is not null) diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Verification/Sha256ChecksumVerifier.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Verification/Sha256ChecksumVerifier.cs index 907c2c7..b2e7a89 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Verification/Sha256ChecksumVerifier.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Verification/Sha256ChecksumVerifier.cs @@ -1,4 +1,3 @@ -using System.Linq; using System.Security.Cryptography; using System.Text; @@ -27,13 +26,13 @@ namespace NextIteration.SpectreConsole.SelfUpdate.Verification public sealed class Sha256ChecksumVerifier : IPackageVerifier { private static readonly string[] ManifestNames = - { + [ "SHA256SUMS.txt", "SHA256SUMS", "sha256sums.txt", "sha256sums", "checksums.txt", - }; + ]; private readonly IUpdateSource _source; @@ -57,15 +56,9 @@ public async Task VerifyAsync(string downloadedFilePath, RemoteRelease release, ArgumentNullException.ThrowIfNull(release); ArgumentNullException.ThrowIfNull(asset); - var expected = TryReadMetadataSha256(asset) - ?? await TryFetchManifestSha256Async(release, asset, ct).ConfigureAwait(false); - - if (expected is null) - { - throw new UpdateException( + var expected = (TryReadMetadataSha256(asset) + ?? await TryFetchManifestSha256Async(release, asset, ct).ConfigureAwait(false)) ?? throw new UpdateException( $"SHA-256 hash for '{asset.Name}' is not available. The asset's metadata does not include a 'sha256' entry, and no SHA256SUMS.txt asset was found on release '{release.Tag}'."); - } - var actual = await ComputeSha256Async(downloadedFilePath, ct).ConfigureAwait(false); if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) { @@ -89,7 +82,10 @@ public async Task VerifyAsync(string downloadedFilePath, RemoteRelease release, { var manifest = release.Assets.FirstOrDefault(a => ManifestNames.Contains(a.Name, StringComparer.OrdinalIgnoreCase)); - if (manifest is null) return null; + if (manifest is null) + { + return null; + } using var ms = new MemoryStream(); await _source.DownloadAssetAsync(manifest, ms, progress: null, ct).ConfigureAwait(false); diff --git a/src/NextIteration.SpectreConsole.SelfUpdate/Verification/Sha256SumsManifest.cs b/src/NextIteration.SpectreConsole.SelfUpdate/Verification/Sha256SumsManifest.cs index 9638f97..c691da6 100644 --- a/src/NextIteration.SpectreConsole.SelfUpdate/Verification/Sha256SumsManifest.cs +++ b/src/NextIteration.SpectreConsole.SelfUpdate/Verification/Sha256SumsManifest.cs @@ -9,7 +9,7 @@ namespace NextIteration.SpectreConsole.SelfUpdate.Verification /// internal static class Sha256SumsManifest { - private static readonly char[] FieldSeparators = { ' ', '\t' }; + private static readonly char[] FieldSeparators = [' ', '\t']; public static IReadOnlyDictionary Parse(string content) { @@ -19,16 +19,29 @@ public static IReadOnlyDictionary Parse(string content) foreach (var rawLine in content.Split('\n')) { var line = rawLine.Trim(); - if (line.Length == 0 || line.StartsWith('#')) continue; + if (line.Length == 0 || line.StartsWith('#')) + { + continue; + } var split = line.IndexOfAny(FieldSeparators); - if (split <= 0) continue; + if (split <= 0) + { + continue; + } var hex = line[..split].Trim(); var name = line[(split + 1)..].Trim().TrimStart('*'); - if (hex.Length != 64 || name.Length == 0) continue; - if (!IsHex(hex)) continue; + if (hex.Length != 64 || name.Length == 0) + { + continue; + } + + if (!IsHex(hex)) + { + continue; + } result[name] = hex.ToLowerInvariant(); } @@ -40,7 +53,10 @@ private static bool IsHex(string s) foreach (var c in s) { var ok = c is >= '0' and <= '9' || c is >= 'a' and <= 'f' || c is >= 'A' and <= 'F'; - if (!ok) return false; + if (!ok) + { + return false; + } } return true; } diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/CommandConfiguratorExtensionsTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/CommandConfiguratorExtensionsTests.cs index c9a2ec5..9afddfa 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/CommandConfiguratorExtensionsTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/CommandConfiguratorExtensionsTests.cs @@ -13,9 +13,9 @@ namespace NextIteration.SpectreConsole.SelfUpdate.Tests { public sealed class CommandConfiguratorExtensionsTests { - private static readonly string[] HelpArgs = { "--help" }; - private static readonly string[] UpdateHelpArgs = { "update", "--help" }; - private static readonly string[] OtaHelpArgs = { "ota", "--help" }; + private static readonly string[] HelpArgs = ["--help"]; + private static readonly string[] UpdateHelpArgs = ["update", "--help"]; + private static readonly string[] OtaHelpArgs = ["ota", "--help"]; [Fact] public async Task AddUpdateCommand_with_default_name_registers_update_command() diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs index c256761..458bbd4 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Commands/UpdateCommandTests.cs @@ -19,14 +19,14 @@ public sealed class UpdateCommandTests Tag: "v1.4.2", Channel: null, ReleaseNotesUrl: new Uri("https://example.com/r/v1.4.2"), - Assets: Array.Empty(), + Assets: [], PublishedAt: DateTimeOffset.UtcNow); private static readonly RemoteRelease ReleaseV100 = new( Tag: "v1.0.0", Channel: null, ReleaseNotesUrl: null, - Assets: Array.Empty(), + Assets: [], PublishedAt: DateTimeOffset.UtcNow); [Fact] diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/FakeHttpHandler.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/FakeHttpHandler.cs index 85b3da2..573fe42 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/FakeHttpHandler.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/FakeHttpHandler.cs @@ -10,7 +10,7 @@ namespace NextIteration.SpectreConsole.SelfUpdate.Tests.Infrastructure internal sealed class FakeHttpHandler : HttpMessageHandler { public Func? Responder { get; set; } - public List Requests { get; } = new(); + public List Requests { get; } = []; protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { @@ -38,7 +38,11 @@ public static HttpResponseMessage Bytes(byte[] payload, HttpStatusCode status = internal sealed class FakeHttpClientFactory : IHttpClientFactory { private readonly HttpMessageHandler _handler; - public FakeHttpClientFactory(HttpMessageHandler handler) => _handler = handler; + public FakeHttpClientFactory(HttpMessageHandler handler) + { + _handler = handler; + } + public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false); } } diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/RecordingProgress.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/RecordingProgress.cs index af0aa6a..cd093d0 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/RecordingProgress.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/RecordingProgress.cs @@ -19,7 +19,7 @@ namespace NextIteration.SpectreConsole.SelfUpdate.Tests.Infrastructure /// internal sealed class RecordingProgress : IProgress { - private readonly List _reports = new(); + private readonly List _reports = []; /// Everything reported so far, as a point-in-time copy. public IReadOnlyList Snapshot @@ -28,7 +28,7 @@ public IReadOnlyList Snapshot { lock (_reports) { - return _reports.ToArray(); + return [.. _reports]; } } } diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/TempDir.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/TempDir.cs index 98730bb..ada1106 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/TempDir.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/TempDir.cs @@ -19,7 +19,7 @@ public TempDir(string? prefix = null) Directory.CreateDirectory(Path); } - public string Combine(params string[] parts) => System.IO.Path.Combine(new[] { Path }.Concat(parts).ToArray()); + public string Combine(params string[] parts) => System.IO.Path.Combine([Path, .. parts]); public void Dispose() { diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/TestRegistrar.cs b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/TestRegistrar.cs index 5e315df..94c8eb5 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/TestRegistrar.cs +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/Infrastructure/TestRegistrar.cs @@ -37,7 +37,10 @@ private sealed class TestResolver : ITypeResolver { private readonly IServiceProvider _provider; - public TestResolver(IServiceProvider provider) => _provider = provider; + public TestResolver(IServiceProvider provider) + { + _provider = provider; + } public object? Resolve(Type? type) => type is null ? null : _provider.GetService(type); } diff --git a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/NextIteration.SpectreConsole.SelfUpdate.Tests.csproj b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/NextIteration.SpectreConsole.SelfUpdate.Tests.csproj index e0263f8..282884e 100644 --- a/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/NextIteration.SpectreConsole.SelfUpdate.Tests.csproj +++ b/tests/NextIteration.SpectreConsole.SelfUpdate.Tests/NextIteration.SpectreConsole.SelfUpdate.Tests.csproj @@ -21,8 +21,14 @@ CA2007 (ConfigureAwait) doesn't apply in test contexts; there is no SynchronizationContext to recapture. + + IDE0005 (remove unnecessary usings) only runs in-build when + GenerateDocumentationFile is true, which this project sets to + false. With EnforceCodeStyleInBuild on it would hard-error + demanding the doc file be enabled. It still gates the shipping + project, where the doc file is on. STANDARD.md 2.7. --> - $(NoWarn);CA1707;CA1515;CA2007 + $(NoWarn);CA1707;CA1515;CA2007;IDE0005