diff --git a/AppDataStorage.Test/AppDataTests.cs b/AppDataStorage.Test/AppDataTests.cs
index 81e927d..8e5b12a 100644
--- a/AppDataStorage.Test/AppDataTests.cs
+++ b/AppDataStorage.Test/AppDataTests.cs
@@ -441,6 +441,24 @@ public void TestLoadOrCreateHandlesCorruptFile()
Assert.AreEqual(string.Empty, appData.Data, "Data should be default if loaded from corrupt file.");
}
+ [TestMethod]
+ public void TestLoadOrCreateArchivesAnUnreadableFileInsteadOfDeletingIt()
+ {
+ using TestAppData original = CreateTestAppDataWithContent("years of user settings");
+ original.Save();
+ AbsoluteFilePath filePath = original.FilePath;
+ string unreadable = AppData.FileSystem.File.ReadAllText(filePath).Replace("{", "{,", StringComparison.Ordinal);
+ AppData.FileSystem.File.WriteAllText(filePath, unreadable);
+
+ TestAppData appData = TestAppData.LoadOrCreate();
+
+ Assert.AreEqual(string.Empty, appData.Data, "Data should be default if the file could not be read.");
+
+ string[] archived = AppData.FileSystem.Directory.GetFiles(filePath.AbsoluteDirectoryPath.ToString(), $"{Path.GetFileName(filePath.ToString())}.corrupt.*");
+ Assert.AreEqual(1, archived.Length, "The unreadable file must be kept, not deleted.");
+ Assert.AreEqual(unreadable, AppData.FileSystem.File.ReadAllText(archived[0]), "The archive must hold the original content.");
+ }
+
[TestMethod]
public void TestLoadOrCreateHandlesNullJsonFile()
{
diff --git a/AppDataStorage/AppData.cs b/AppDataStorage/AppData.cs
index 14c1df4..e440925 100644
--- a/AppDataStorage/AppData.cs
+++ b/AppDataStorage/AppData.cs
@@ -220,19 +220,32 @@ private static bool TryRestoreFrom(AbsoluteFilePath candidate, AbsoluteFilePath
// Archiving rather than deleting keeps the recovered content for inspection, and moving it
// out of the way is what stops a candidate that turns out to be unreadable from being
- // promoted again on the next attempt: LoadOrCreate deletes a file it cannot deserialize and
+ // promoted again on the next attempt: LoadOrCreate archives a file it cannot deserialize and
// reads again, which would otherwise restore the same bad content forever.
+ _ = Archive(candidate, string.Empty);
+ return true;
+ }
+
+ ///
+ /// Moves aside under a unique timestamped name, so its content
+ /// survives without being read again in its place.
+ ///
+ /// The file to archive.
+ /// A suffix naming why the file was archived, such as ".corrupt", or empty.
+ /// The path the file was archived to.
+ internal static AbsoluteFilePath Archive(AbsoluteFilePath filePath, string label)
+ {
string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
- AbsoluteFilePath archived = candidate.WithSuffix($".{timestamp}");
+ AbsoluteFilePath archived = filePath.WithSuffix($"{label}.{timestamp}");
int counter = 0;
while (FileSystem.File.Exists(archived))
{
counter++;
- archived = candidate.WithSuffix($".{timestamp}_{counter}");
+ archived = filePath.WithSuffix($"{label}.{timestamp}_{counter}");
}
- FileSystem.File.Move(candidate, archived);
- return true;
+ FileSystem.File.Move(filePath, archived);
+ return archived;
}
///
@@ -581,9 +594,11 @@ public static T LoadOrCreate(RelativeDirectoryPath? subdirectory, FileName? file
}
catch (JsonException)
{
- // file was corrupt or could not be deserialized
- // delete and try load a backup
- AppData.FileSystem.File.Delete(newAppData.FilePath);
+ // The file could not be read as T, whether from corruption, a hand edit or a model
+ // change in an app update. It is usually the only copy of the user's data, since a
+ // successful save removes the backup, so it is archived rather than deleted. The
+ // retry then finds it missing and falls back to a temp or backup file, or defaults.
+ _ = AppData.Archive(newAppData.FilePath, ".corrupt");
return LoadOrCreate(subdirectory, fileName);
}
}