From 6ba72d891a2b53bf3606e66e4c64bebee0fe179a Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Sat, 30 May 2026 04:33:52 +0000 Subject: [PATCH] Make backup retention configurable BackupService captured a fixed retention count at construction. It now reads retention lazily via a Func provider, wired in Bootstrap to AppConfig.BackupRetention, so a change made in Settings takes effect on the next write-triggered backup with no restart. Provider values are clamped to at least 1 at prune time so a bad config never wipes backups. Adds a "Backups to keep" NumberBox (1-200) to the Settings page and the BackupRetention property/persistence on SettingsViewModel. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 ++ src/Snipdeck.App/Bootstrap.cs | 2 +- src/Snipdeck.App/Views/ShellPage.xaml | 10 ++++ src/Snipdeck.Core/Models/AppConfig.cs | 4 ++ src/Snipdeck.Core/Services/BackupService.cs | 37 ++++++++++---- .../ViewModels/SettingsViewModel.cs | 16 +++++++ .../Services/BackupServiceTests.cs | 48 +++++++++++++++++++ 7 files changed, 113 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24e6e80..c225e20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Configurable backup retention.** Choose how many timestamped store backups + to keep (default 20) from Settings → "Backups to keep". The count is honoured + on the next write-triggered backup, with no restart required. + ## [0.1.0-alpha.1] - 2026-05-30 First packaged release. Cuts an alpha to exercise the release pipeline diff --git a/src/Snipdeck.App/Bootstrap.cs b/src/Snipdeck.App/Bootstrap.cs index 9059fdb..52bc1f4 100644 --- a/src/Snipdeck.App/Bootstrap.cs +++ b/src/Snipdeck.App/Bootstrap.cs @@ -31,7 +31,7 @@ public static IServiceProvider Build() var snipStoreFilePath = Path.Combine(storageDirectory, _snipStoreFileName); var snipStore = new JsonSnipStore(snipStoreFilePath); - var backupService = new BackupService(snipStoreFilePath, backupDirectory, clock); + var backupService = new BackupService(snipStoreFilePath, backupDirectory, clock, () => config.BackupRetention); var iconStorage = new IconAssetStorage(storageDirectory); var services = new ServiceCollection(); diff --git a/src/Snipdeck.App/Views/ShellPage.xaml b/src/Snipdeck.App/Views/ShellPage.xaml index ba97529..b797d94 100644 --- a/src/Snipdeck.App/Views/ShellPage.xaml +++ b/src/Snipdeck.App/Views/ShellPage.xaml @@ -175,6 +175,16 @@ MaxWidth="320" /> + + + + diff --git a/src/Snipdeck.Core/Models/AppConfig.cs b/src/Snipdeck.Core/Models/AppConfig.cs index db90cd5..a848b64 100644 --- a/src/Snipdeck.Core/Models/AppConfig.cs +++ b/src/Snipdeck.Core/Models/AppConfig.cs @@ -4,12 +4,16 @@ public sealed class AppConfig { public const int CurrentSchemaVersion = 1; + public const int DefaultBackupRetention = 20; + public int SchemaVersion { get; set; } = CurrentSchemaVersion; public string? StoragePath { get; set; } public string? BackupDirectory { get; set; } + public int BackupRetention { get; set; } = DefaultBackupRetention; + public ThemePreference Theme { get; set; } = ThemePreference.System; public HotkeyBinding Hotkey { get; set; } = HotkeyBinding.Default; diff --git a/src/Snipdeck.Core/Services/BackupService.cs b/src/Snipdeck.Core/Services/BackupService.cs index c49f61e..b01be87 100644 --- a/src/Snipdeck.Core/Services/BackupService.cs +++ b/src/Snipdeck.Core/Services/BackupService.cs @@ -7,30 +7,49 @@ namespace Snipdeck.Core.Services { public sealed class BackupService : IBackupService, IDisposable { - public const int DefaultRetention = 20; + public const int DefaultRetention = AppConfig.DefaultBackupRetention; private const string _filenamePrefix = "snipstore_"; private const string _filenameSuffix = ".json"; private const string _timestampFormat = "yyyyMMdd_HHmmssfff"; private readonly string _sourceFilePath; private readonly IClock _clock; - private readonly int _retention; + private readonly Func _retentionProvider; private readonly SemaphoreSlim _gate = new(1, 1); + /// + /// Constructs the service with a fixed retention count. Validated eagerly. + /// public BackupService(string sourceFilePath, string backupDirectory, IClock clock, int retention = DefaultRetention) + : this(sourceFilePath, backupDirectory, clock, ValidateFixedRetention(retention)) + { + } + + /// + /// Constructs the service with a retention + /// read lazily on each prune, so a change to the backing configuration takes + /// effect on the next backup without re-creating the service. Provided values + /// are clamped to at least 1 at use time — a bad configuration value must never + /// crash a backup. + /// + public BackupService(string sourceFilePath, string backupDirectory, IClock clock, Func retentionProvider) { ArgumentException.ThrowIfNullOrWhiteSpace(sourceFilePath); ArgumentException.ThrowIfNullOrWhiteSpace(backupDirectory); ArgumentNullException.ThrowIfNull(clock); - if (retention < 1) - { - throw new ArgumentOutOfRangeException(nameof(retention), retention, "Retention must be at least 1."); - } + ArgumentNullException.ThrowIfNull(retentionProvider); _sourceFilePath = sourceFilePath; BackupDirectory = backupDirectory; _clock = clock; - _retention = retention; + _retentionProvider = retentionProvider; + } + + private static Func ValidateFixedRetention(int retention) + { + return retention < 1 + ? throw new ArgumentOutOfRangeException(nameof(retention), retention, "Retention must be at least 1.") + : () => retention; } public string BackupDirectory { get; } @@ -107,11 +126,13 @@ private IEnumerable EnumerateBackupFiles() private void PruneStaleBackups() { + var retention = Math.Max(1, _retentionProvider()); + var ordered = EnumerateBackupFiles() .OrderByDescending(name => name, StringComparer.Ordinal) .ToList(); - for (var i = _retention; i < ordered.Count; i++) + for (var i = retention; i < ordered.Count; i++) { try { diff --git a/src/Snipdeck.Core/ViewModels/SettingsViewModel.cs b/src/Snipdeck.Core/ViewModels/SettingsViewModel.cs index f59711d..ca96263 100644 --- a/src/Snipdeck.Core/ViewModels/SettingsViewModel.cs +++ b/src/Snipdeck.Core/ViewModels/SettingsViewModel.cs @@ -47,6 +47,7 @@ public SettingsViewModel( HotkeyDisplay = FormatHotkey(config.Hotkey); StorageDirectory = config.StoragePath ?? pathProvider.DefaultStorageDirectory; BackupDirectory = config.BackupDirectory ?? pathProvider.DefaultBackupDirectory; + BackupRetention = config.BackupRetention; _suppressPersist = false; var assembly = typeof(SettingsViewModel).Assembly; @@ -79,6 +80,9 @@ public SettingsViewModel( [ObservableProperty] public partial int ThemeIndex { get; set; } + [ObservableProperty] + public partial int BackupRetention { get; set; } + [ObservableProperty] public partial CloseBehaviour CloseBehaviour { get; set; } @@ -106,6 +110,17 @@ partial void OnThemeIndexChanged(int value) _ = PersistAsync(); } + partial void OnBackupRetentionChanged(int value) + { + if (value < 1) + { + // Re-entrant set lands back here with a valid value, which persists. + BackupRetention = 1; + return; + } + _ = PersistAsync(); + } + partial void OnCloseBehaviourIndexChanged(int value) { CloseBehaviour = value == 1 ? CloseBehaviour.Exit : CloseBehaviour.HideToTray; @@ -148,6 +163,7 @@ private async Task PersistAsync() } _config.Theme = Theme; _config.CloseBehaviour = CloseBehaviour; + _config.BackupRetention = BackupRetention; await _settingsStore.SaveAsync(_config).ConfigureAwait(true); } diff --git a/tests/Snipdeck.Core.Tests/Services/BackupServiceTests.cs b/tests/Snipdeck.Core.Tests/Services/BackupServiceTests.cs index fc7b965..0f6d1e7 100644 --- a/tests/Snipdeck.Core.Tests/Services/BackupServiceTests.cs +++ b/tests/Snipdeck.Core.Tests/Services/BackupServiceTests.cs @@ -132,6 +132,54 @@ public async Task CreateBackupAsync_prunes_backups_beyond_retention() Assert.Equal("snipstore_20260529_120004000.json", remaining[2]); } + [Fact] + public async Task CreateBackupAsync_reads_retention_lazily_from_provider() + { + WriteSource(); + var clock = new FakeClock(new DateTimeOffset(2026, 5, 29, 12, 0, 0, TimeSpan.Zero)); + var retention = 5; + var service = new BackupService(_sourcePath, _backupDirectory, clock, () => retention); + + // Fill up under the initial retention of 5. + for (var i = 0; i < 5; i++) + { + await service.CreateBackupAsync(); + clock.Advance(TimeSpan.FromSeconds(1)); + } + Assert.Equal(5, Directory.GetFiles(_backupDirectory, "snipstore_*.json").Length); + + // Tighten retention and back up again: the next prune honours the new value. + retention = 2; + await service.CreateBackupAsync(); + + Assert.Equal(2, Directory.GetFiles(_backupDirectory, "snipstore_*.json").Length); + } + + [Fact] + public async Task CreateBackupAsync_clamps_non_positive_provider_value_to_one() + { + WriteSource(); + var clock = new FakeClock(new DateTimeOffset(2026, 5, 29, 12, 0, 0, TimeSpan.Zero)); + var service = new BackupService(_sourcePath, _backupDirectory, clock, () => 0); + + for (var i = 0; i < 4; i++) + { + await service.CreateBackupAsync(); + clock.Advance(TimeSpan.FromSeconds(1)); + } + + // A zero/negative provider value never wipes everything: at least one is kept. + Assert.Single(Directory.GetFiles(_backupDirectory, "snipstore_*.json")); + } + + [Fact] + public void Provider_constructor_rejects_null_provider() + { + var clock = new FakeClock(DateTimeOffset.UtcNow); + Assert.Throws( + () => new BackupService(_sourcePath, _backupDirectory, clock, retentionProvider: null!)); + } + [Fact] public async Task PruneStep_does_not_touch_unrelated_files_in_backup_directory() {