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()
{