Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/Snipdeck.App/Bootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 10 additions & 0 deletions src/Snipdeck.App/Views/ShellPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,16 @@
MaxWidth="320" />
</tk:SettingsCard>

<tk:SettingsCard Header="Backups to keep"
Description="Older backups beyond this count are pruned after each save.">
<NumberBox Value="{x:Bind BackupRetention, Mode=TwoWay}"
Minimum="1"
Maximum="200"
SmallChange="1"
SpinButtonPlacementMode="Inline"
MinWidth="120" />
</tk:SettingsCard>

<tk:SettingsCard Header="Updates"
Description="Check the GitHub releases feed for a newer build.">
<StackPanel Orientation="Horizontal" Spacing="8">
Expand Down
4 changes: 4 additions & 0 deletions src/Snipdeck.Core/Models/AppConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
37 changes: 29 additions & 8 deletions src/Snipdeck.Core/Services/BackupService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> _retentionProvider;
private readonly SemaphoreSlim _gate = new(1, 1);

/// <summary>
/// Constructs the service with a fixed retention count. Validated eagerly.
/// </summary>
public BackupService(string sourceFilePath, string backupDirectory, IClock clock, int retention = DefaultRetention)
: this(sourceFilePath, backupDirectory, clock, ValidateFixedRetention(retention))
{
}

/// <summary>
/// Constructs the service with a retention <paramref name="retentionProvider"/>
/// 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.
/// </summary>
public BackupService(string sourceFilePath, string backupDirectory, IClock clock, Func<int> 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<int> ValidateFixedRetention(int retention)
{
return retention < 1
? throw new ArgumentOutOfRangeException(nameof(retention), retention, "Retention must be at least 1.")
: () => retention;
}

public string BackupDirectory { get; }
Expand Down Expand Up @@ -107,11 +126,13 @@ private IEnumerable<string> 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
{
Expand Down
16 changes: 16 additions & 0 deletions src/Snipdeck.Core/ViewModels/SettingsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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; }

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -148,6 +163,7 @@ private async Task PersistAsync()
}
_config.Theme = Theme;
_config.CloseBehaviour = CloseBehaviour;
_config.BackupRetention = BackupRetention;
await _settingsStore.SaveAsync(_config).ConfigureAwait(true);
}

Expand Down
48 changes: 48 additions & 0 deletions tests/Snipdeck.Core.Tests/Services/BackupServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArgumentNullException>(
() => new BackupService(_sourcePath, _backupDirectory, clock, retentionProvider: null!));
}

[Fact]
public async Task PruneStep_does_not_touch_unrelated_files_in_backup_directory()
{
Expand Down
Loading