Skip to content

Commit 950b303

Browse files
diluculoclaude
andcommitted
Add ToolCacheIndexStore.EvictAsync
Removes a single package's pinned state from _index.json so the next resolve falls through to a fresh NuGet metadata query, ignoring the CachedWithRefresh TTL for that package only. The rest of the cache is preserved. Motivated by host UIs that want to expose a manual 'update to latest' button — clearing one pin lets users bypass the 24h cache wait without flushing the entire index. Before this, callers had to reach for UpdateAsync(mutator) and reproduce internal schema details: lower-case the key themselves, build an OrdinalIgnoreCase Dictionary, construct ToolPackageState, set SchemaVersion. EvictAsync owns all of that. Semantics: - Case-insensitive on packageId (the index normalizes to lower-case; forcing callers to do the same was a trap every consumer hit). - Idempotent: missing entry returns false without throwing, so the manual-update UX can call this unconditionally before reconnecting. - Atomic write inherited from UpdateAsync — partial reads can't observe a half-evicted state. Three new unit tests cover the happy path, case-insensitivity, and the missing-entry no-op. Existing five tests untouched; full suite still green on both net8.0 and net10.0 (8/8). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e492489 commit 950b303

2 files changed

Lines changed: 99 additions & 0 deletions

File tree

src/FieldCure.ToolHost/ToolCacheIndexStore.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,45 @@ public async Task<ToolCacheIndex> UpdateAsync(Func<ToolCacheIndex, ToolCacheInde
124124
return next;
125125
}
126126

127+
/// <summary>
128+
/// Removes a single package's cached state from the index so the next resolve for that
129+
/// package falls through to a fresh NuGet metadata query regardless of TTL. Idempotent:
130+
/// a missing entry is a no-op. The lookup is case-insensitive — callers don't have to
131+
/// know the index normalizes ids to lowercase.
132+
/// </summary>
133+
/// <remarks>
134+
/// Intended for host UIs that surface a manual "Update to latest" action: clearing one
135+
/// pin without flushing the entire index lets users bypass the
136+
/// <see cref="ToolVersionPolicy.CachedWithRefresh"/> wait without affecting unrelated
137+
/// packages or losing the rest of the cache.
138+
/// </remarks>
139+
/// <param name="packageId">NuGet package id (case-insensitive).</param>
140+
/// <param name="ct">Cancellation token.</param>
141+
/// <returns><see langword="true"/> if an entry was removed; <see langword="false"/> if no entry existed.</returns>
142+
public async Task<bool> EvictAsync(string packageId, CancellationToken ct = default)
143+
{
144+
ArgumentException.ThrowIfNullOrWhiteSpace(packageId);
145+
146+
var key = packageId.ToLowerInvariant();
147+
var removed = false;
148+
149+
_ = await UpdateAsync(current =>
150+
{
151+
if (!current.Packages.ContainsKey(key))
152+
return current;
153+
154+
var next = new Dictionary<string, ToolPackageState>(current.Packages, StringComparer.OrdinalIgnoreCase);
155+
removed = next.Remove(key);
156+
return new ToolCacheIndex
157+
{
158+
SchemaVersion = ToolCacheIndex.CurrentSchemaVersion,
159+
Packages = next,
160+
};
161+
}, ct).ConfigureAwait(false);
162+
163+
return removed;
164+
}
165+
127166
/// <summary>Releases the synchronization primitive backing concurrent access.</summary>
128167
public void Dispose() => _gate.Dispose();
129168
}

tests/FieldCure.ToolHost.Tests/ToolCacheIndexStoreTests.cs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,64 @@ public async Task UpdateAsync_AppliesMutatorAndPersists()
118118
var reloaded = await store.LoadAsync();
119119
_ = reloaded.Packages.Should().ContainKey("new.pkg");
120120
}
121+
122+
[Xunit.Fact]
123+
public async Task EvictAsync_RemovesEntryAndReturnsTrue()
124+
{
125+
using ToolCacheIndexStore store = new(_indexPath);
126+
await SavePackageAsync(store, "Demo.Tool", "1.1.0");
127+
128+
var removed = await store.EvictAsync("Demo.Tool");
129+
130+
_ = removed.Should().BeTrue();
131+
var reloaded = await store.LoadAsync();
132+
_ = reloaded.Packages.Should().NotContainKey("demo.tool");
133+
}
134+
135+
[Xunit.Fact]
136+
public async Task EvictAsync_IsCaseInsensitive()
137+
{
138+
// Persisted keys are lower-cased; the caller shouldn't have to know that. Mixed-case
139+
// input must still hit the entry — otherwise the manual "Update to latest" UX breaks
140+
// for anyone who happens to type the package id with its display casing.
141+
using ToolCacheIndexStore store = new(_indexPath);
142+
await SavePackageAsync(store, "Demo.Tool", "1.1.0");
143+
144+
var removed = await store.EvictAsync("DEMO.TOOL");
145+
146+
_ = removed.Should().BeTrue();
147+
var reloaded = await store.LoadAsync();
148+
_ = reloaded.Packages.Should().BeEmpty();
149+
}
150+
151+
[Xunit.Fact]
152+
public async Task EvictAsync_MissingEntry_ReturnsFalseAndPreservesOthers()
153+
{
154+
using ToolCacheIndexStore store = new(_indexPath);
155+
await SavePackageAsync(store, "keep.me", "2.0.0");
156+
157+
var removed = await store.EvictAsync("not.cached");
158+
159+
_ = removed.Should().BeFalse();
160+
var reloaded = await store.LoadAsync();
161+
_ = reloaded.Packages.Should().ContainKey("keep.me");
162+
}
163+
164+
/// <summary>Persists a single package entry into the index for test setup.</summary>
165+
private static Task SavePackageAsync(ToolCacheIndexStore store, string packageId, string version)
166+
{
167+
return store.SaveAsync(new ToolCacheIndex
168+
{
169+
SchemaVersion = ToolCacheIndex.CurrentSchemaVersion,
170+
Packages = new Dictionary<string, ToolPackageState>(StringComparer.OrdinalIgnoreCase)
171+
{
172+
[packageId.ToLowerInvariant()] = new()
173+
{
174+
KnownCachedVersions = new[] { version },
175+
PinnedVersion = version,
176+
LastLatestCheckUtc = DateTimeOffset.UtcNow,
177+
},
178+
},
179+
});
180+
}
121181
}

0 commit comments

Comments
 (0)