feat(secrets-cli): recent-targets store for the interactive console
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
|
||||
|
||||
/// <summary>
|
||||
/// Persists the operator's recently-used store targets (names + appsettings paths ONLY — never
|
||||
/// key material or secret values) so the interactive console can offer quick re-selection.
|
||||
/// </summary>
|
||||
public sealed class RecentTargetsStore
|
||||
{
|
||||
private const int MaxEntries = 10;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
private readonly string _filePath;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>Creates the store bound to a JSON file path, using an optional injected clock.</summary>
|
||||
/// <param name="filePath">The JSON file to load from and save to.</param>
|
||||
/// <param name="timeProvider">
|
||||
/// Clock used to stamp <see cref="RecentTarget.LastUsedUtc"/>. Defaults to
|
||||
/// <see cref="TimeProvider.System"/> when omitted.
|
||||
/// </param>
|
||||
public RecentTargetsStore(string filePath, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_filePath = filePath;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>One remembered target: a display name, its appsettings.json path, and when it was last used.</summary>
|
||||
/// <param name="Name">Display name for the target (e.g. the app name).</param>
|
||||
/// <param name="AppSettingsPath">Filesystem path to the target's appsettings.json.</param>
|
||||
/// <param name="LastUsedUtc">Timestamp of the most recent selection of this target.</param>
|
||||
public sealed record RecentTarget(string Name, string AppSettingsPath, DateTimeOffset LastUsedUtc);
|
||||
|
||||
/// <summary>Loads the recents list, newest first. A missing or unreadable/corrupt file yields an empty list.</summary>
|
||||
public IReadOnlyList<RecentTarget> Load()
|
||||
{
|
||||
if (!File.Exists(_filePath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_filePath);
|
||||
var entries = JsonSerializer.Deserialize<List<RecentTarget>>(json, SerializerOptions);
|
||||
return entries ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Upserts a target by path, moves it to the front, stamps now, caps the list at 10, and saves.</summary>
|
||||
/// <param name="name">Display name for the target.</param>
|
||||
/// <param name="appSettingsPath">Filesystem path to the target's appsettings.json — the dedup key.</param>
|
||||
public void Touch(string name, string appSettingsPath)
|
||||
{
|
||||
var existing = Load();
|
||||
var remaining = existing.Where(e => !string.Equals(e.AppSettingsPath, appSettingsPath, StringComparison.Ordinal));
|
||||
var touched = new RecentTarget(name, appSettingsPath, _timeProvider.GetUtcNow());
|
||||
var updated = new List<RecentTarget> { touched };
|
||||
updated.AddRange(remaining.Take(MaxEntries - 1));
|
||||
|
||||
Save(updated);
|
||||
}
|
||||
|
||||
private void Save(IReadOnlyList<RecentTarget> entries)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_filePath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(entries, SerializerOptions);
|
||||
var tmpPath = _filePath + ".tmp";
|
||||
File.WriteAllText(tmpPath, json);
|
||||
File.Move(tmpPath, _filePath, overwrite: true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user