From a4703d060d921e80e9cff0a7dbe198f1ff223bd5 Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Tue, 2 Jul 2024 06:39:39 -0600 Subject: [PATCH] Three-way diffing Synchronizer (#1707) * Work on the new synchronizer * Rough draft of mappings * Fix some broken rules * Good stopping place for the weekend, can build a sync plan from the disk state and game state * Fixing the loadout synchronizer tests one by one * Loadout syncronizer tests pass * Fix disk diff code * It compiles, time to run the full test suite * All tests pass * Benchmarks are all messed up, but fixed up the rest of the app * Delete the outdated benchmarks :( * Fix more tests * Fix executable bit setting on *nix * Fix a bug with deleted files * Add some docs/ADRs documenting the process * Add a test around SMAPI moving files into mods during ingestion, fixed an error with the sync code exposed by the test * Make the signature builder struct readonly * Remove default leg from the giant switch expression --- .../Loadouts/ApplyOnly/FileTreeToDisk.cs | 53 - .../Common/FlattenedLoadoutToFileTree.cs | 40 - .../Loadouts/Common/GetPreviousState.cs | 40 - .../Common/LoadoutToFlattenedLoadout.cs | 36 - .../Harness/ASynchronizerBenchmark.cs | 14 - .../Loadouts/Harness/DummyFileStore.cs | 2 +- .../Loadouts/IngestOnly/BackupNewFiles.cs | 76 -- .../Loadouts/IngestOnly/DiskToFileTree.cs | 60 - .../IngestOnly/FileTreeToFlattenedLoadout.cs | 62 - .../Loadouts/IngestOnly/GetDiskState.cs | 42 - .../Loadouts/IngestOnly/GetState.cs | 37 - .../backend/0014-synchronizer-structure.md | 215 ++++ .../NexusMods.Abstractions.Games/AGame.cs | 6 +- .../NexusMods.Abstractions.IO/IFileStore.cs | 2 +- .../ALoadoutSynchronizer.cs | 1110 ++++++----------- .../FileTree.cs | 8 +- .../ILoadoutSynchronizer.cs | 72 +- .../IStandardizedLoadoutSyncronizer.cs | 95 -- .../Rules/ActionMapping.cs | 130 ++ .../Rules/Actions.cs | 45 + .../Rules/Signature.cs | 146 +++ .../Rules/SignatureShorthand.cs | 377 ++++++ .../SyncActionGroupings.cs | 37 + .../SyncTree.cs | 11 + .../SyncTreeNode.cs | 18 + .../IApplyService.cs | 18 +- .../Emitters/DependencyDiagnosticEmitter.cs | 3 - .../Emitters/MissingSMAPIEmitter.cs | 1 - ...dDatabaseCompatibilityDiagnosticEmitter.cs | 1 - .../Emitters/VersionDiagnosticEmitter.cs | 1 - .../NexusMods.Games.StardewValley/Interop.cs | 1 - .../StardewValley.cs | 2 +- .../StardewValleyLoadoutSynchronizer.cs | 43 +- .../Controls/Trees/DiffTreeViewModel.cs | 2 +- .../LeftMenu/Items/ApplyControlViewModel.cs | 4 +- src/NexusMods.DataModel/ApplyService.cs | 61 +- .../Verbs/LoadoutManagementVerbs.cs | 55 +- .../Extensions/LoadoutExtensions.cs | 36 - .../Loadouts/Extensions/LoadoutExtensions.cs | 31 - src/NexusMods.DataModel/NxFileStore.cs | 2 +- src/NexusMods.DataModel/ToolManager.cs | 8 +- .../StardewValleySynchronizerTests.cs | 74 ++ .../AGameTest.cs | 4 + .../VerbTests/ModManagementVerbs.cs | 5 +- .../ALoadoutSynchronizerTests.cs | 237 +--- .../ApplyServiceTests.cs | 23 +- .../Harness/ADataModelTest.cs | 4 +- ...rRuleTests.RulesAreAsExpected.verified.txt | 374 ++++++ .../SynchronizerRuleTests.cs | 129 ++ tests/NexusMods.DataModel.Tests/ToolTests.cs | 5 - 50 files changed, 2089 insertions(+), 1769 deletions(-) delete mode 100644 benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/ApplyOnly/FileTreeToDisk.cs delete mode 100644 benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Common/FlattenedLoadoutToFileTree.cs delete mode 100644 benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Common/GetPreviousState.cs delete mode 100644 benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Common/LoadoutToFlattenedLoadout.cs delete mode 100644 benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/BackupNewFiles.cs delete mode 100644 benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/DiskToFileTree.cs delete mode 100644 benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/FileTreeToFlattenedLoadout.cs delete mode 100644 benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/GetDiskState.cs delete mode 100644 benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/GetState.cs create mode 100644 docs/developers/decisions/backend/0014-synchronizer-structure.md delete mode 100644 src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/IStandardizedLoadoutSyncronizer.cs create mode 100644 src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/Rules/ActionMapping.cs create mode 100644 src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/Rules/Actions.cs create mode 100644 src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/Rules/Signature.cs create mode 100644 src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/Rules/SignatureShorthand.cs create mode 100644 src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/SyncActionGroupings.cs create mode 100644 src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/SyncTree.cs create mode 100644 src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/SyncTreeNode.cs delete mode 100644 src/NexusMods.DataModel/LoadoutSynchronizer/Extensions/LoadoutExtensions.cs delete mode 100644 src/NexusMods.DataModel/Loadouts/Extensions/LoadoutExtensions.cs create mode 100644 tests/Games/NexusMods.Games.StardewValley.Tests/StardewValleySynchronizerTests.cs create mode 100644 tests/NexusMods.DataModel.Tests/SynchronizerRuleTests.RulesAreAsExpected.verified.txt create mode 100644 tests/NexusMods.DataModel.Tests/SynchronizerRuleTests.cs diff --git a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/ApplyOnly/FileTreeToDisk.cs b/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/ApplyOnly/FileTreeToDisk.cs deleted file mode 100644 index 076763c93d..0000000000 --- a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/ApplyOnly/FileTreeToDisk.cs +++ /dev/null @@ -1,53 +0,0 @@ -using BenchmarkDotNet.Attributes; -using NexusMods.Abstractions.DiskState; -using NexusMods.Abstractions.GameLocators; -using NexusMods.Abstractions.Games.DTO; -using NexusMods.Benchmarks.Benchmarks.Loadouts.Harness; -using NexusMods.Benchmarks.Interfaces; - -namespace NexusMods.Benchmarks.Benchmarks.Loadouts.ApplyOnly; - -[MemoryDiagnoser] -[BenchmarkInfo("LoadoutSynchronizer: FileTreeToDisk", "[Apply Step 4/5] Compares expected state against new state and extracts files to target. (Extraction Skipped)")] -public class FileTreeToDisk : ASynchronizerBenchmark, IBenchmark -{ - [ParamsSource(nameof(ValuesForFilePath))] - // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global - // ReSharper disable once MemberCanBePrivate.Global - // ReSharper disable once FieldCanBeMadeReadOnly.Global - public string FileName = null!; - private FlattenedLoadout _flattenedLoadout = null!; - private FileTree _fileTree = null!; - private DiskStateTree _prevState = null!; - - public IEnumerable ValuesForFilePath => new[] - { - Path.GetFileName(Assets.Loadouts.FileLists.SkyrimFileList), - Path.GetFileName(Assets.Loadouts.FileLists.StardewValleyFileList), - Path.GetFileName(Assets.Loadouts.FileLists.NPC3DFileList), - }; - - [GlobalSetup] - public void Setup() - { - var filePath = Assets.Loadouts.FileLists.GetFileListPathByFileName(FileName); - Init("Benchmark Mod Files", filePath); - Task.Run(async () => - { - _flattenedLoadout = await _defaultSynchronizer.LoadoutToFlattenedLoadout(_datamodel.BaseLoadout); - _fileTree = await _defaultSynchronizer.FlattenedLoadoutToFileTree(_flattenedLoadout, _datamodel.BaseLoadout); - _prevState = _datamodel.DiskStateRegistry.GetState(_installation)!; - return 0; - }).Wait(); -#pragma warning disable CS0618 // Type or member is obsolete - _defaultSynchronizer.SetFileStore(new DummyFileStore()); -#pragma warning restore CS0618 // Type or member is obsolete - } - - [Benchmark] - public async Task ToDisk() - { - return await _defaultSynchronizer.FileTreeToDiskImpl - (_fileTree, _datamodel.BaseLoadout, _flattenedLoadout, _prevState, _installation,false); - } -} diff --git a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Common/FlattenedLoadoutToFileTree.cs b/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Common/FlattenedLoadoutToFileTree.cs deleted file mode 100644 index f90eb6c0a9..0000000000 --- a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Common/FlattenedLoadoutToFileTree.cs +++ /dev/null @@ -1,40 +0,0 @@ -using BenchmarkDotNet.Attributes; -using NexusMods.Abstractions.GameLocators; -using NexusMods.Abstractions.Games.DTO; -using NexusMods.Benchmarks.Benchmarks.Loadouts.Harness; -using NexusMods.Benchmarks.Interfaces; - -namespace NexusMods.Benchmarks.Benchmarks.Loadouts.Common; - -[MemoryDiagnoser] -[BenchmarkInfo("LoadoutSynchronizer: FlattenedLoadoutToFileTree", "[Apply Step 2/5, Ingest 2/9] Converts a flattened loadout to a file tree.")] -public class FlattenedLoadoutToFileTree : ASynchronizerBenchmark, IBenchmark -{ - [ParamsSource(nameof(ValuesForFilePath))] - // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global - // ReSharper disable once MemberCanBePrivate.Global - // ReSharper disable once FieldCanBeMadeReadOnly.Global - public string FileName = null!; - private FlattenedLoadout _flattenedLoadout = null!; - - public IEnumerable ValuesForFilePath => new[] - { - Path.GetFileName(Assets.Loadouts.FileLists.SkyrimFileList), - Path.GetFileName(Assets.Loadouts.FileLists.StardewValleyFileList), - Path.GetFileName(Assets.Loadouts.FileLists.NPC3DFileList), - }; - - [GlobalSetup] - public void Setup() - { - var filePath = Assets.Loadouts.FileLists.GetFileListPathByFileName(FileName); - Init("Benchmark Mod Files", filePath); - _flattenedLoadout = Task.Run(async () => await _defaultSynchronizer.LoadoutToFlattenedLoadout(_datamodel.BaseLoadout)).Result; - } - - [Benchmark] - public async Task ToFileTree() - { - return await _defaultSynchronizer.FlattenedLoadoutToFileTree(_flattenedLoadout, _datamodel.BaseLoadout); - } -} diff --git a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Common/GetPreviousState.cs b/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Common/GetPreviousState.cs deleted file mode 100644 index fef6eb5c64..0000000000 --- a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Common/GetPreviousState.cs +++ /dev/null @@ -1,40 +0,0 @@ -using BenchmarkDotNet.Attributes; -using NexusMods.Abstractions.DiskState; -using NexusMods.Abstractions.Games.DTO; -using NexusMods.Benchmarks.Benchmarks.Loadouts.Harness; -using NexusMods.Benchmarks.Interfaces; - -namespace NexusMods.Benchmarks.Benchmarks.Loadouts.Common; - -[MemoryDiagnoser] -[BenchmarkInfo("LoadoutSynchronizer: GetPreviousState", "[Apply Step 3/5, Ingest 3/9] Retrieves the serialized previous (expected) disk state.")] -public class GetPreviousState : ASynchronizerBenchmark, IBenchmark -{ - [ParamsSource(nameof(ValuesForFilePath))] - // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global - // ReSharper disable once MemberCanBePrivate.Global - // ReSharper disable once FieldCanBeMadeReadOnly.Global - public string FileName = null!; - private FlattenedLoadout _flattenedLoadout = null!; - - public IEnumerable ValuesForFilePath => new[] - { - Path.GetFileName(Assets.Loadouts.FileLists.SkyrimFileList), - Path.GetFileName(Assets.Loadouts.FileLists.StardewValleyFileList), - Path.GetFileName(Assets.Loadouts.FileLists.NPC3DFileList), - }; - - [GlobalSetup] - public void Setup() - { - var filePath = Assets.Loadouts.FileLists.GetFileListPathByFileName(FileName); - Init("Benchmark Mod Files", filePath); - _flattenedLoadout = Task.Run(async () => await _defaultSynchronizer.LoadoutToFlattenedLoadout(_datamodel.BaseLoadout)).Result; - } - - [Benchmark] - public DiskStateTree GetState() - { - return _datamodel.DiskStateRegistry.GetState(_installation)!; - } -} diff --git a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Common/LoadoutToFlattenedLoadout.cs b/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Common/LoadoutToFlattenedLoadout.cs deleted file mode 100644 index 2b4fa002ac..0000000000 --- a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Common/LoadoutToFlattenedLoadout.cs +++ /dev/null @@ -1,36 +0,0 @@ -using BenchmarkDotNet.Attributes; -using NexusMods.Abstractions.Games.DTO; -using NexusMods.Benchmarks.Benchmarks.Loadouts.Harness; -using NexusMods.Benchmarks.Interfaces; - -namespace NexusMods.Benchmarks.Benchmarks.Loadouts.Common; - -[MemoryDiagnoser] -[BenchmarkInfo("LoadoutSynchronizer: LoadoutToFlattenedLoadout", "[Apply Step 1/5, Ingest 1/9] Converts a loadout to a flattened loadout.")] -public class LoadoutToFlattenedLoadout : ASynchronizerBenchmark, IBenchmark -{ - [ParamsSource(nameof(ValuesForFilePath))] - // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global - // ReSharper disable once MemberCanBePrivate.Global - // ReSharper disable once FieldCanBeMadeReadOnly.Global - public string FileName = null!; - - public IEnumerable ValuesForFilePath => new[] - { - Path.GetFileName(Assets.Loadouts.FileLists.SkyrimFileList), - Path.GetFileName(Assets.Loadouts.FileLists.StardewValleyFileList), - Path.GetFileName(Assets.Loadouts.FileLists.NPC3DFileList), - }; - - [GlobalSetup] - public void Setup() - { - Init("Benchmark Mod Files", Assets.Loadouts.FileLists.GetFileListPathByFileName(FileName)); - } - - [Benchmark] - public async Task FlattenLoadout() - { - return await _defaultSynchronizer.LoadoutToFlattenedLoadout(_datamodel.BaseLoadout); - } -} diff --git a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Harness/ASynchronizerBenchmark.cs b/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Harness/ASynchronizerBenchmark.cs index b18668e2b6..3a390c287e 100644 --- a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Harness/ASynchronizerBenchmark.cs +++ b/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Harness/ASynchronizerBenchmark.cs @@ -34,18 +34,4 @@ protected void Init(string baseModName, string fileList) _installation = _datamodel.BaseLoadout.InstallationInstance; _diskStateRegistry = _serviceProvider.GetRequiredService(); } - - protected void InitForIngest() - { - // We apply the files of a new loadout (without updating the loadout itself) - // This way we have loose files to ingest. - Task.Run(async () => - { - // Do an apply, but without updating the loadout revision. - var flattenedLoadout = await _defaultSynchronizer.LoadoutToFlattenedLoadout(_datamodel.BaseLoadout); - var fileTree = await _defaultSynchronizer.FlattenedLoadoutToFileTree(flattenedLoadout, _datamodel.BaseLoadout); - var prevState = _datamodel.DiskStateRegistry.GetState(_installation)!; - await _defaultSynchronizer.FileTreeToDiskImpl(fileTree, _datamodel.BaseLoadout, flattenedLoadout, prevState, _installation,false); - }).Wait(); - } } diff --git a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Harness/DummyFileStore.cs b/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Harness/DummyFileStore.cs index 779c21a2e5..df53529e27 100644 --- a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Harness/DummyFileStore.cs +++ b/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Harness/DummyFileStore.cs @@ -16,7 +16,7 @@ public Task BackupFiles(IEnumerable backups, CancellationToke return Task.CompletedTask; } - public Task ExtractFiles((Hash Hash, AbsolutePath Dest)[] files, CancellationToken token = default) + public Task ExtractFiles(IEnumerable<(Hash Hash, AbsolutePath Dest)> files, CancellationToken token = default) { return Task.CompletedTask; } diff --git a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/BackupNewFiles.cs b/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/BackupNewFiles.cs deleted file mode 100644 index df6ac2004f..0000000000 --- a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/BackupNewFiles.cs +++ /dev/null @@ -1,76 +0,0 @@ -using BenchmarkDotNet.Attributes; -using NexusMods.Abstractions.DiskState; -using NexusMods.Abstractions.GameLocators; -using NexusMods.Abstractions.Games.DTO; -using NexusMods.Abstractions.Loadouts.Files; -using NexusMods.Benchmarks.Benchmarks.Loadouts.Harness; -using NexusMods.Benchmarks.Interfaces; -using NexusMods.Hashing.xxHash64; -using NexusMods.Paths; - -namespace NexusMods.Benchmarks.Benchmarks.Loadouts.IngestOnly; - -[MemoryDiagnoser] -[BenchmarkInfo("LoadoutSynchronizer: BackupNewFiles", - "[Ingest 8/9] Backs up any new files in the loadout. [LoadoutSynchronizer Overhead only test].")] -// Needed because DB keeps growing between runs, and DB perf can be inconsistent enough that it'll run all 100 runs, -// taking forever. -public class BackupNewFiles : ASynchronizerBenchmark, IBenchmark -{ - [ParamsSource(nameof(ValuesForFilePath))] - // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global - // ReSharper disable once MemberCanBePrivate.Global - // ReSharper disable once FieldCanBeMadeReadOnly.Global - public string FileName = null!; - private FlattenedLoadout _prevFlattenedLoadout = null!; - private FileTree _prevFileTree = null!; - private DiskStateTree _prevDiskState = null!; - private DiskStateTree _diskState = null!; - private (GamePath GamePath, Hash Hash, Size Size)[] _fileTree = null!; - - public IEnumerable ValuesForFilePath => new[] - { - Path.GetFileName(Assets.Loadouts.FileLists.SkyrimFileList), - Path.GetFileName(Assets.Loadouts.FileLists.StardewValleyFileList), - Path.GetFileName(Assets.Loadouts.FileLists.NPC3DFileList), - }; - - [GlobalSetup] - public void Setup() - { - var filePath = Assets.Loadouts.FileLists.GetFileListPathByFileName(FileName); - Init("Benchmark Mod Files", filePath); - InitForIngest(); - var loadout = _datamodel.BaseLoadout; - - // Init for function. - Task.Run(async () => - { - _prevFlattenedLoadout = await _defaultSynchronizer.LoadoutToFlattenedLoadout(loadout); - _prevFileTree = await _defaultSynchronizer.FlattenedLoadoutToFileTree(_prevFlattenedLoadout, loadout); - _prevDiskState = _diskStateRegistry.GetState(_installation)!; - - // Get the new disk state - _diskState = await _defaultSynchronizer.GetDiskState(_installation); - _fileTree = (await _defaultSynchronizer - .DiskToFileTree(_diskState, loadout, _prevFileTree, _prevDiskState)) - .GetAllDescendentFiles() - .Select(f => - { - f.Item.Value.TryGetAsStoredFile(out var stored); - return (f.Item.GamePath, stored!.Hash, stored.Size); - } - ).ToArray(); - }).Wait(); - -#pragma warning disable CS0618 // Type or member is obsolete - _defaultSynchronizer.SetFileStore(new DummyFileStore()); -#pragma warning restore CS0618 // Type or member is obsolete - } - - [Benchmark] - public async Task BackupNewFiles_OverheadOnly() - { - await _defaultSynchronizer.BackupNewFiles(_installation, _fileTree); - } -} diff --git a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/DiskToFileTree.cs b/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/DiskToFileTree.cs deleted file mode 100644 index 09950c3a43..0000000000 --- a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/DiskToFileTree.cs +++ /dev/null @@ -1,60 +0,0 @@ -using BenchmarkDotNet.Attributes; -using NexusMods.Abstractions.DiskState; -using NexusMods.Abstractions.GameLocators; -using NexusMods.Abstractions.Games.DTO; -using NexusMods.Benchmarks.Benchmarks.Loadouts.Harness; -using NexusMods.Benchmarks.Interfaces; - -namespace NexusMods.Benchmarks.Benchmarks.Loadouts.IngestOnly; - -[MemoryDiagnoser] -[BenchmarkInfo("LoadoutSynchronizer: DiskToFileTree", - "[Ingest 5/9] Create new file tree from the current disk state and the previous file tree.")] -[SimpleJob(1,3,3,1)] -// Needed because DB keeps growing between runs, and DB perf can be inconsistent enough that it'll run all 100 runs, -// taking forever. -public class DiskToFileTree : ASynchronizerBenchmark, IBenchmark -{ - [ParamsSource(nameof(ValuesForFilePath))] - // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global - // ReSharper disable once MemberCanBePrivate.Global - // ReSharper disable once FieldCanBeMadeReadOnly.Global - public string FileName = null!; - private FlattenedLoadout _prevFlattenedLoadout = null!; - private FileTree _prevFileTree = null!; - private DiskStateTree _prevDiskState = null!; - private DiskStateTree _diskState = null!; - - public IEnumerable ValuesForFilePath => new[] - { - Path.GetFileName(Assets.Loadouts.FileLists.SkyrimFileList), - Path.GetFileName(Assets.Loadouts.FileLists.StardewValleyFileList), - Path.GetFileName(Assets.Loadouts.FileLists.NPC3DFileList), - }; - - [GlobalSetup] - public void Setup() - { - var filePath = Assets.Loadouts.FileLists.GetFileListPathByFileName(FileName); - Init("Benchmark Mod Files", filePath); - InitForIngest(); - var loadout = _datamodel.BaseLoadout; - - // Init for function. - Task.Run(async () => - { - _prevFlattenedLoadout = await _defaultSynchronizer.LoadoutToFlattenedLoadout(loadout); - _prevFileTree = await _defaultSynchronizer.FlattenedLoadoutToFileTree(_prevFlattenedLoadout, loadout); - _prevDiskState = _diskStateRegistry.GetState(_installation)!; - - // Get the new disk state - _diskState = await _defaultSynchronizer.GetDiskState(_installation); - }).Wait(); - } - - [Benchmark] - public async Task DiskToFileTreee() - { - return await _defaultSynchronizer.DiskToFileTree(_diskState, _datamodel.BaseLoadout, _prevFileTree, _prevDiskState); - } -} diff --git a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/FileTreeToFlattenedLoadout.cs b/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/FileTreeToFlattenedLoadout.cs deleted file mode 100644 index 5b50cab9a5..0000000000 --- a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/FileTreeToFlattenedLoadout.cs +++ /dev/null @@ -1,62 +0,0 @@ -using BenchmarkDotNet.Attributes; -using NexusMods.Abstractions.DiskState; -using NexusMods.Abstractions.GameLocators; -using NexusMods.Abstractions.Games.DTO; -using NexusMods.Benchmarks.Benchmarks.Loadouts.Harness; -using NexusMods.Benchmarks.Interfaces; - -namespace NexusMods.Benchmarks.Benchmarks.Loadouts.IngestOnly; - -[MemoryDiagnoser] -[BenchmarkInfo("LoadoutSynchronizer: FileTreeToFlattenedLoadout", - "[Ingest 6/9] Converts a file tree into a flattened loadout by assigning files to mods, " + - "reusing previous assignments when possible, and creating new mods for unassigned files.")] -// Needed because DB keeps growing between runs, and DB perf can be inconsistent enough that it'll run all 100 runs, -// taking forever. -public class FileTreeToFlattenedLoadout : ASynchronizerBenchmark, IBenchmark -{ - [ParamsSource(nameof(ValuesForFilePath))] - // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global - // ReSharper disable once MemberCanBePrivate.Global - // ReSharper disable once FieldCanBeMadeReadOnly.Global - public string FileName = null!; - private FlattenedLoadout _prevFlattenedLoadout = null!; - private FileTree _prevFileTree = null!; - private DiskStateTree _prevDiskState = null!; - private DiskStateTree _diskState = null!; - private FileTree _fileTree = null!; - - public IEnumerable ValuesForFilePath => new[] - { - Path.GetFileName(Assets.Loadouts.FileLists.SkyrimFileList), - Path.GetFileName(Assets.Loadouts.FileLists.StardewValleyFileList), - Path.GetFileName(Assets.Loadouts.FileLists.NPC3DFileList), - }; - - [GlobalSetup] - public void Setup() - { - var filePath = Assets.Loadouts.FileLists.GetFileListPathByFileName(FileName); - Init("Benchmark Mod Files", filePath); - InitForIngest(); - var loadout = _datamodel.BaseLoadout; - - // Init for function. - Task.Run(async () => - { - _prevFlattenedLoadout = await _defaultSynchronizer.LoadoutToFlattenedLoadout(loadout); - _prevFileTree = await _defaultSynchronizer.FlattenedLoadoutToFileTree(_prevFlattenedLoadout, loadout); - _prevDiskState = _diskStateRegistry.GetState(_installation)!; - - // Get the new disk state - _diskState = await _defaultSynchronizer.GetDiskState(_installation); - _fileTree = await _defaultSynchronizer.DiskToFileTree(_diskState, loadout, _prevFileTree, _prevDiskState); - }).Wait(); - } - - [Benchmark] - public async Task FileTreeToFlattenedLoadouto() - { - return await _defaultSynchronizer.FileTreeToFlattenedLoadout(_fileTree, _datamodel.BaseLoadout, _prevFlattenedLoadout); - } -} diff --git a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/GetDiskState.cs b/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/GetDiskState.cs deleted file mode 100644 index 41ff7d5295..0000000000 --- a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/GetDiskState.cs +++ /dev/null @@ -1,42 +0,0 @@ -using BenchmarkDotNet.Attributes; -using NexusMods.Abstractions.DiskState; -using NexusMods.Benchmarks.Benchmarks.Loadouts.Harness; -using NexusMods.Benchmarks.Interfaces; - -namespace NexusMods.Benchmarks.Benchmarks.Loadouts.IngestOnly; - -[MemoryDiagnoser] -[BenchmarkInfo("LoadoutSynchronizer: GetDiskState (With Cache)", - "[Ingest 4/9] Get the current state of the game on disk. " + - "In this test, the entire previous state is cached, so this is more of an overhead test rather than actually indexing new data.")] -public class GetDiskState : ASynchronizerBenchmark, IBenchmark -{ - [ParamsSource(nameof(ValuesForFilePath))] - // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global - // ReSharper disable once MemberCanBePrivate.Global - // ReSharper disable once FieldCanBeMadeReadOnly.Global - public string FileName = null!; - - public IEnumerable ValuesForFilePath => new[] - { - Path.GetFileName(Assets.Loadouts.FileLists.SkyrimFileList), - Path.GetFileName(Assets.Loadouts.FileLists.StardewValleyFileList), - Path.GetFileName(Assets.Loadouts.FileLists.NPC3DFileList), - }; - - [GlobalSetup] - public void Setup() - { - var filePath = Assets.Loadouts.FileLists.GetFileListPathByFileName(FileName); - Init("Benchmark Mod Files", filePath); - InitForIngest(); - } - - [Benchmark] - public async Task GetCurrentDiskState_WithCachedHashes() - { - // Note: This benchmark has a 'cache' of previous index, so it's not a real-world scenario. - // So this is purely an overhead test. - return await _defaultSynchronizer.GetDiskState(_installation); - } -} diff --git a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/GetState.cs b/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/GetState.cs deleted file mode 100644 index f164dd2a9e..0000000000 --- a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/IngestOnly/GetState.cs +++ /dev/null @@ -1,37 +0,0 @@ -using BenchmarkDotNet.Attributes; -using NexusMods.Abstractions.DiskState; -using NexusMods.Benchmarks.Benchmarks.Loadouts.Harness; -using NexusMods.Benchmarks.Interfaces; - -namespace NexusMods.Benchmarks.Benchmarks.Loadouts.IngestOnly; - -[MemoryDiagnoser] -[BenchmarkInfo("LoadoutSynchronizer: GetExpectedDiskState", - "[Ingest 3/9] Get the serialized expected state of the game on disk.")] -public class GetState : ASynchronizerBenchmark, IBenchmark -{ - [ParamsSource(nameof(ValuesForFilePath))] - // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global - // ReSharper disable once MemberCanBePrivate.Global - // ReSharper disable once FieldCanBeMadeReadOnly.Global - public string FileName = null!; - - public IEnumerable ValuesForFilePath => new[] - { - Path.GetFileName(Assets.Loadouts.FileLists.SkyrimFileList), - }; - - [GlobalSetup] - public void Setup() - { - var filePath = Assets.Loadouts.FileLists.GetFileListPathByFileName(FileName); - Init("Benchmark Mod Files", filePath); - InitForIngest(); - } - - [Benchmark] - public DiskStateTree GetCachedDiskState() - { - return _diskStateRegistry.GetState(_installation)!; - } -} diff --git a/docs/developers/decisions/backend/0014-synchronizer-structure.md b/docs/developers/decisions/backend/0014-synchronizer-structure.md new file mode 100644 index 0000000000..2aab8b2025 --- /dev/null +++ b/docs/developers/decisions/backend/0014-synchronizer-structure.md @@ -0,0 +1,215 @@ +# Structure of the Synchronizer + +## Context and Problem Statement + +For the first part of development (until the start of alpha) the app was based on a two-method model. At various parts of the +application either the `Apply` or `Ingest` methods would be called which would either sync from the loadout to the disk, or pull +changes from the disk into the loadout. This model was fairly simple, was better suited for the fully immutable, git-like, +every loadout is a fork, model that we were using at the time. With the introduction of MnemonicDB, we moved to a structure +where a loadout was treated less like `git` and more like a financial ledger. As the saying goes: "accountants use ink". +That is to say, there is no editing of the past, only the adding of new data. + +The comparison between git and a ledger is best explained this way: in git, every change to a folder results in a new reality, +the old reality exists, and there is no direct connection between the two. The contents of each "reality" are hashed and the +name of the reality is the hash of the top level folder (the commit SHA). So there is no way in this model to know that one +file in one SHA is the same as another, except by comparing the contents and the path. In a ledger, each account has a specific +id. Changing the balance of the account involves adding a transaction, and the current state of the account is the sum of all +transactions. Every account has an id that sticks with it for the entire life of the account. + +This impacts the design of the synchronizer when it comes with how to deal with data external to the application. In a git-like +model, an apply and ingest operation result in a fork of the loadout. Then later on if two loadouts need to be reconciled, +they would be merged into a single loadout. This is very close to the fork/merge model of git. In a ledger model, any changes +to the loadout must be reconciled to the ledger at transaction time. + +## Three-way merge + +The cleanest way to perform this reconciliation is to have a three-way merge, that examines the current loadout, the disk, +and the last applied state of the loadout. Any changes on disk can be detected by comparing them to the previously applied state. + +As an example, lets setup a situation where we will look at the state of the disk, previous state, and the loadout as a tuple. +In this shorthand we will use `A` and `B` to represent hashes, and `x` to represent a missing file. Based on this we can +create some example states: + +- xxA - The file exists in the Loadout, but not on disk and was not in the previous state, so it should be extracted to disk +- xAB - The file exists in the Loadout and the previous state, but the file hash has changed, so it should be extracted to disk +- Axx - The file exists on disk, and not in the Loadout or the previous state, so it's a new file that should be added to the Loadout +- ABB - The file exists in all 3 locations, but the file on disk has changed after it was last applied, so it should be ingested +- ABC - This is a conflict, it's not clear what should be done here, so the user should be prompted to resolve the conflict + +## Decision Drivers +When deciding how to perform this 3 way merge, previous failures to implement a 3 way merge were considered for the reasons +why they failed. The main issue was the complexity of writing all the possible states. Later on we'll see that we have around +92 possible states, and handling these as a set of `if-then-else` trees is a nightmare. + +In addition, this sync process needed to be fast enough to handle large numbers of files, and be side-effect free in the analysis +phase. Several parts of the app display the possible changes that will be made if a sync is performed, and these UI elements +depend on the sync being side effect free and fast. + +Since there are 92 states, it was desirable to have all the possible combinations and mappings to actions exist in a single +file, with an optimal one-line-per state mapping. This would allow for easy debugging and testing of the sync process. + +## Implementation Details +Early on in the design process it was discovered that most of the states in the three way merge consist of boolean flags. And that +the entire process roughly breaks down into 3 phases: + +1) Gather information +2) Calculate the operations to be performed +3) Perform the operations + +If phases 1 and 2 are completely side effect free, then the UI can use just the first two phases to display the changes that will +be performed, and then only perform the execution phase if the user agrees to the changes. + +If every possible state is boiled down to a set of boolean flags, then all the possible states can be represented as a single +unsigned integer, and that integer can be mapped to a set of operations. If these operations are themselves represented as +boolean flags, then all mappings can be expressed as a dictionary of integers to integers. In this design the state of the +of a file is known as the "Signature" of the file, and the resulting actions are known as `Actions`. They are both represented +in C# as an ushort flag enum. + +### Signature Builder + +To create a signature, the helper struct known as `SignatureBuilder` was created. This struct can be filled out with the +correct data, then the `.Build()` method can be called to create the signature. The signature looks like the following: + +```csharp +[Flags] +public enum Signature : ushort +{ + /// + /// Empty signature, used only as a way to detect an uninitialized signature. + /// + Empty = 0, + + /// + /// True if the file exists on disk. + /// + DiskExists = 1, + + /// + /// True if the file exists in the previous state. + /// + PrevExists = 2, + + /// + /// True if the file exists in the loadout. + /// + LoadoutExists = 4, + + /// + /// True if the hashes of the disk and previous state are equal. + /// + DiskEqualsPrev = 8, + + /// + /// True if the hashes of the previous state and loadout are equal. + /// + PrevEqualsLoadout = 16, + + /// + /// True if the hashes of the disk and loadout are equal. + /// + DiskEqualsLoadout = 32, + + /// + /// True if the file on disk is already archived. + /// + DiskArchived = 64, + + /// + /// True if the file in the previous state is archived. + /// + PrevArchived = 128, + + /// + /// True if the file in the loadout is archived. + /// + LoadoutArchived = 256, + + /// + /// True if the path is ignored, i.e. it is on a game-specific ignore list. + /// + PathIsIgnored = 512, +} +``` + +As one can see, the entire state is represented in 10 bits of integers, which does mean that all the possible states of this +bit field are roughly one million. However in practice several of these flags are mutually exclusive or imply other flags. For +example, if the disk and loadout are equal, and the disk and previous are equal, then the loadout and previous are equal. In addition, +if the loadout does not have an entry, there is no way the entry can be archived. Reducing all these illogical states reduces +the search space down to 92 states. These states are represented in another enum known as the `SignatureShorthand` enum. And +are simply pre-defined enum values that are easier for humans to digest than a raw bitfield, or jagged lines of or-ing flags. + +```csharp +/// +/// Summary of all 92 possible signatures in a somewhat readable format +/// +public enum SignatureShorthand : ushort +{ + /// + /// LoadoutExists + /// + xxA_xxx_i = 0x0004, + /// + /// LoadoutExists, LoadoutArchived + /// + xxA_xxX_i = 0x0104, + /// + /// LoadoutExists, PathIsIgnored + /// + xxA_xxx_I = 0x0204, +} +``` + +Both the SignatureShorthand enum and Actions enum are combined in the `ActionMapping` class as a large switch statement: + +```csharp + + public static Actions MapAction(SignatureShorthand shorthand) + { + // Format of the shorthand: + // xxx_yyy_z -> xxx: Loadout, yyy: Archive, z: Ignore path + // xxx: a tuple of `(Disk, Previous, Loadout)` states. + // A `x` means no state because that source has no value for the path. + // A `A`, `B`, `C` are placeholders for the hash of the file, so `AAA` means all three sources have the same hash, while `BAA` means the hash is different on disk + // from either the previous state or the loadout. + // yyy: a tuple of `(Disk, Previous, Loadout)` archive states. `XXX` means all three sources are archived (regardless of their hash) and `Xxx` means the disk is archived but the previous and loadout states are not. + // `z`: either `i` or `I`, where `i` means the path is not ignored and `I` means the path is ignored. + // The easiest way to think of this is that a capital letter means the existence of data, while a lowercase letter means the absence of data or a false value. + return shorthand switch + { + xxA_xxx_i => WarnOfUnableToExtract, + xxA_xxX_i => ExtractToDisk, + xxA_xxx_I => WarnOfUnableToExtract, + xxA_xxX_I => ExtractToDisk, + xAx_xxx_i => DoNothing, +} +``` + +The TL;DR of all this, is that if you want to edit the behavior of the synchronizer, look at the contents of `ActionMapping.cs` and +modify the mappings for the desired state. + +## High-level design +All of this behavior is encapsulated in the `ALoadoutSynchronizer` class. The previous phases of flattening loadouts and sorting +mods is replaced with the following phases: + +* BuildSyncTree + * All files in the loadout are grouped by their path + * Any files in the groups that are in a mod that is disabled are filtered out + * Any group that has more than one file is handed to the `SelectWinningFile` virtual method which returns a single that is conisered +the winning override for the given path. + * The winning file is then added to the sync tree under the path of the group + * The previous state of the game folders is then added to the sync tree indexed by the game paths + * The disk state of the game folders is then added to the sync tree indexed by the game paths + * The sync tree then is a combination of the three tree states +* ProcessSyncTree + * Each entry in the sync tree is loaded into a `SignatureBuilder`. + * Hashes for each entry are sent to the virtual method `HaveArchive` to determine if the file is archived + * The `SignatureBuilder` is then built and the resulting signature added to the sync tree + * The signature is then mapped to an action using the `ActionMapping` class + * As the actions are calculated, each node in the tree is added to an action grouping, which is a bag of SyncTreeNodes indexed by +the action that should be performed. A given node can be in multiple action groups if it has multiple actions that should be performed. +* RunGroupings + * Each group of actions is then executed in order. The order of the actions is determined by the value of the action in the `Actions` enum + * When a new file is ingested, it is added to the `Overrides` mod. After all new files are added to Overrides, the files are handed to the virtual method +`MoveNewFilesToMods` which allows any inheriting class to move the files to game specific mods. + * Some actions may modify the loadout, the new loadout is returned from the action + diff --git a/src/Abstractions/NexusMods.Abstractions.Games/AGame.cs b/src/Abstractions/NexusMods.Abstractions.Games/AGame.cs index 1a78676d7c..9f3ad2b107 100644 --- a/src/Abstractions/NexusMods.Abstractions.Games/AGame.cs +++ b/src/Abstractions/NexusMods.Abstractions.Games/AGame.cs @@ -23,7 +23,7 @@ public abstract class AGame : IGame { private IReadOnlyCollection? _installations; private readonly IEnumerable _gameLocators; - private readonly Lazy _synchronizer; + private readonly Lazy _synchronizer; private readonly Lazy> _installers; private readonly IServiceProvider _provider; @@ -35,7 +35,7 @@ protected AGame(IServiceProvider provider) _provider = provider; _gameLocators = provider.GetServices(); // In a Lazy so we don't get a circular dependency - _synchronizer = new Lazy(() => MakeSynchronizer(provider)); + _synchronizer = new Lazy(() => MakeSynchronizer(provider)); _installers = new Lazy>(() => MakeInstallers(provider)); } @@ -45,7 +45,7 @@ protected AGame(IServiceProvider provider) /// /// /// - protected virtual IStandardizedLoadoutSynchronizer MakeSynchronizer(IServiceProvider provider) + protected virtual ILoadoutSynchronizer MakeSynchronizer(IServiceProvider provider) { return new DefaultSynchronizer(provider); } diff --git a/src/Abstractions/NexusMods.Abstractions.IO/IFileStore.cs b/src/Abstractions/NexusMods.Abstractions.IO/IFileStore.cs index 8515814231..98722fb094 100644 --- a/src/Abstractions/NexusMods.Abstractions.IO/IFileStore.cs +++ b/src/Abstractions/NexusMods.Abstractions.IO/IFileStore.cs @@ -31,7 +31,7 @@ public interface IFileStore /// /// /// - Task ExtractFiles((Hash Hash, AbsolutePath Dest)[] files, CancellationToken token = default); + Task ExtractFiles(IEnumerable<(Hash Hash, AbsolutePath Dest)> files, CancellationToken token = default); /// /// Extract the given files from archives. diff --git a/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/ALoadoutSynchronizer.cs b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/ALoadoutSynchronizer.cs index e9b792186e..59eb34716d 100644 --- a/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/ALoadoutSynchronizer.cs +++ b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/ALoadoutSynchronizer.cs @@ -1,27 +1,21 @@ using System.Collections.Concurrent; -using System.Diagnostics; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; +using DynamicData.Kernel; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using NexusMods.Abstractions.DataModel.Entities.Sorting; using NexusMods.Abstractions.DiskState; using NexusMods.Abstractions.GameLocators; -using NexusMods.Abstractions.Games.DTO; -using NexusMods.Abstractions.Games.Loadouts; using NexusMods.Abstractions.Games.Loadouts.Sorting; using NexusMods.Abstractions.Games.Trees; using NexusMods.Abstractions.IO; using NexusMods.Abstractions.IO.StreamFactories; using NexusMods.Abstractions.Loadouts.Files; using NexusMods.Abstractions.Loadouts.Mods; -using NexusMods.Abstractions.MnemonicDB.Attributes; +using NexusMods.Abstractions.Loadouts.Synchronizers.Rules; using NexusMods.Abstractions.MnemonicDB.Attributes.Extensions; using NexusMods.Extensions.BCL; using NexusMods.Hashing.xxHash64; using NexusMods.MnemonicDB.Abstractions; using NexusMods.MnemonicDB.Abstractions.BuiltInEntities; -using NexusMods.MnemonicDB.Abstractions.Models; using NexusMods.MnemonicDB.Abstractions.TxFunctions; using NexusMods.Paths; using File = NexusMods.Abstractions.Loadouts.Files.File; @@ -32,7 +26,7 @@ namespace NexusMods.Abstractions.Loadouts.Synchronizers; /// Base class for loadout synchronizers, provides some common functionality. Does not have to be user, /// but reduces a lot of boilerplate, and is highly recommended. /// -public class ALoadoutSynchronizer : IStandardizedLoadoutSynchronizer +public class ALoadoutSynchronizer : ILoadoutSynchronizer { private readonly ILogger _logger; private readonly IFileHashCache _hashCache; @@ -85,236 +79,16 @@ protected ALoadoutSynchronizer(IServiceProvider provider) : this( { } - - #region IStandardizedLoadoutSynchronizer Implementation - - /// - public async ValueTask LoadoutToFlattenedLoadout(Loadout.ReadOnly loadout) - { - var sorted = await SortMods(loadout); - return ModsToFlattenedLoadout(sorted); - } - - private static FlattenedLoadout ModsToFlattenedLoadout(IEnumerable sorted) - { - var modArray = sorted.ToArray(); - var numItems = modArray.Sum(mod => mod.Files.Count); - var dict = new Dictionary(numItems); - foreach (var mod in modArray) - { - if (!mod.Enabled) - continue; - - foreach (var file in mod.Files) - { - dict[file.To] = file; - } - } - - return FlattenedLoadout.Create(dict); - } - - /// - public ValueTask FlattenedLoadoutToFileTree(FlattenedLoadout flattenedLoadout, Loadout.ReadOnly loadout) - { - return ValueTask.FromResult(FileTree.Create(flattenedLoadout.GetAllDescendentFiles() - .Select(f => KeyValuePair.Create(f.GamePath(), f.Item.Value)))); - } - - - /// - public async Task FileTreeToDisk(FileTree fileTree, Loadout.ReadOnly loadout, FlattenedLoadout flattenedLoadout, DiskStateTree prevState, GameInstallation installation, bool skipIngest = false) - { - // Return the new tree - return await FileTreeToDiskImpl(fileTree, loadout, flattenedLoadout, prevState, installation, true); - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal async Task FileTreeToDiskImpl(FileTree fileTree, Loadout.ReadOnly loadout, FlattenedLoadout flattenedLoadout, DiskStateTree prevState, GameInstallation installation, bool fixFileMode, bool skipIngest = false) + private void CleanDirectories(IEnumerable toDelete, DiskStateTree newTree, GameInstallation installation) { - List> toDelete = new(); - List> toWrite = new(); - List> toExtract = new(); - - Dictionary resultingItems = new(); - - // We'll start by scanning the game folders and comparing it to the previous state. Technically this is a - // three way compare between the disk state, the previous state, and the new state. However if the disk state - // diverges from the previous state, we'll abort, this effectively reduces the process to a two way compare. - foreach (var (_, location) in installation.LocationsRegister.GetTopLevelLocations()) - { - await foreach (var entry in _hashCache.IndexFolderAsync(location)) - { - var gamePath = installation.LocationsRegister.ToGamePath(entry.Path); - - if (!prevState.TryGetValue(gamePath, out var prevEntry)) - { - // File is new, and not in the previous state, so we need to abort and do an ingest - if (skipIngest) - continue; - - HandleNeedIngest(entry); - throw new UnreachableException("HandleNeedIngest should have thrown"); - } - - if (prevEntry.Item.Value.Hash != entry.Hash) - { - // File has changed, so we need to abort and do an ingest - if (skipIngest) - continue; - - HandleNeedIngest(entry); - throw new UnreachableException("HandleNeedIngest should have thrown"); - } - - if (!fileTree.TryGetValue(gamePath, out var newEntry)) - { - // File is unchanged, but is not present in the new tree, so it needs to be deleted - // We don't remove it from the results yet, will do during batch delete - toDelete.Add(KeyValuePair.Create(gamePath, entry)); - continue; - } - - // File didn't change on disk and is present in new tree - resultingItems.Add(newEntry.GamePath(), prevEntry.Item.Value); - - var file = newEntry.Item.Value!; - if (file.TryGetAsDeletedFile(out _)) - { - // File is deleted in the new tree, so add it toDelete and we're done - toDelete.Add(KeyValuePair.Create(gamePath, entry)); - continue; - } - else if (file.TryGetAsStoredFile(out var storedFile)) - { - // StoredFile files are special cased, so we can batch them up and extract them all at once. - // Don't add toExtract to the results yet as we'll need to get the modified file times - // after we extract them - if (storedFile.Hash == entry.Hash) - continue; - - toExtract.Add(KeyValuePair.Create(entry.Path, storedFile)); - } - else if (file.TryGetAsGeneratedFile(out _)) - { - toWrite.Add(KeyValuePair.Create(entry.Path, file)); - } - else - { - _logger.LogError("Unknown file type: {Entity}", file); - } - } - } - - // Now we look for completely new files or files that were deleted on disk - foreach (var item in fileTree.GetAllDescendentFiles()) - { - var path = item.GamePath(); - - // If the file has already been handled above, skip it - if (resultingItems.ContainsKey(path)) - continue; - - var absolutePath = installation.LocationsRegister.GetResolvedPath(path); - - if (prevState.TryGetValue(path, out var prevEntry)) - { - // File is in new tree, was in prev disk state, but wasn't found on disk - if (skipIngest) - continue; - - HandleNeedIngest(prevEntry.Item.Value.ToHashedEntry(absolutePath)); - throw new UnreachableException("HandleNeedIngest should have thrown"); - } - - - var file = item.Item.Value!; - if (file.TryGetAsDeletedFile(out _)) - { - // File is deleted in the new tree, so nothing to do - continue; - } - else if (file.TryGetAsStoredFile(out var storedFile)) - { - toExtract.Add(KeyValuePair.Create(absolutePath, storedFile)); - } - else if (file.TryGetAsGeneratedFile(out _)) - { - toWrite.Add(KeyValuePair.Create(absolutePath, file)); - } - else - { - throw new UnreachableException("No way to handle this file"); - } - } - - // Now delete all the files that need deleting in one batch. - foreach (var entry in toDelete) - { - entry.Value.Path.Delete(); - resultingItems.Remove(entry.Key); - } - - // Write the generated files (could be done in parallel) - foreach (var entry in toWrite) - { - entry.Key.Parent.CreateDirectory(); - await using var outputStream = entry.Key.Create(); - var hash = await WriteGeneratedFile(entry.Value, outputStream, loadout!, flattenedLoadout!, fileTree); - if (hash == null) - { - outputStream.Position = 0; - hash = await outputStream.HashingCopyAsync(Stream.Null, CancellationToken.None); - } - - var gamePath = loadout.InstallationInstance.LocationsRegister.ToGamePath(entry.Key); - resultingItems[gamePath] = new DiskStateEntry - { - Hash = hash!.Value, - Size = Size.From((ulong)outputStream.Length), - LastModified = entry.Key.FileInfo.LastWriteTimeUtc - }; - } - - // Extract all the files that need extracting in one batch. - _logger.LogInformation("Extracting {Count} files", toExtract.Count); - await _fileStore.ExtractFiles(GetFilesToExtract(toExtract)); - - // Update the resulting items with the new file times - var isUnix = _os.IsUnix(); - foreach (var (path, entry) in toExtract) - { - resultingItems[entry.AsFile().To] = new DiskStateEntry - { - Hash = entry.Hash, - Size = entry.Size, - LastModified = path.FileInfo.LastWriteTimeUtc - }; - - // And mark them as executable if necessary, on Unix - if (!isUnix || !fixFileMode) - continue; - - var ext = path.Extension.ToString().ToLower(); - if (ext is not ("" or ".sh" or ".bin" or ".run" or ".py" or ".pl" or ".php" or ".rb" or ".out" - or ".elf")) continue; - - // Note (Sewer): I don't think we'd ever need anything other than just 'user' execute, but you can never - // be sure. Just in case, I'll throw in group and other to match 'chmod +x' behaviour. - var currentMode = path.GetUnixFileMode(); - path.SetUnixFileMode(currentMode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); - } - - var newTree = DiskStateTree.Create(resultingItems); - - // We need to delete any empty directory structures that were left behind var seenDirectories = new HashSet(); var directoriesToDelete = new HashSet(); foreach (var entry in toDelete) { - var parentPath = entry.Key.Parent; + var parentPath = entry.Parent; GamePath? emptyStructureRoot = null; - while (parentPath != entry.Key.GetRootComponent) + while (parentPath != entry.GetRootComponent) { if (seenDirectories.Contains(parentPath)) { @@ -342,25 +116,6 @@ internal async Task FileTreeToDiskImpl(FileTree fileTree, Loadout // Could have other empty directories as children, so we need to delete recursively installation.LocationsRegister.GetResolvedPath(dir).DeleteDirectory(recursive: true); } - - return newTree; - - // Return the new tree - // Quick convert function such that to not be LINQ bottlenecked. - // Needed as separate method because parent method is async. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - static (Hash Hash, AbsolutePath Dest)[] GetFilesToExtract(List> toExtract) - { - (Hash Hash, AbsolutePath Dest)[] entries = GC.AllocateUninitializedArray<(Hash Src, AbsolutePath Dest)>(toExtract.Count); - var toExtractSpan = CollectionsMarshal.AsSpan(toExtract); - for (var x = 0; x < toExtract.Count; x++) - { - ref var item = ref toExtractSpan[x]; - entries[x] = (item.Value.Hash, item.Key); - } - - return entries; - } } /// @@ -368,271 +123,252 @@ public virtual async Task GetDiskState(GameInstallation installat { return await _hashCache.IndexDiskState(installation); } - - /// - /// Called when a file has changed during an apply operation, and a ingest is required. - /// - /// - public virtual void HandleNeedIngest(HashedEntryWithName entry) + + #region ILoadoutSynchronizer Implementation + + protected ModId GetOrCreateOverridesMod(Loadout.ReadOnly loadout, ITransaction tx) { - throw new NeedsIngestException(); + if (loadout.Mods.TryGetFirst(m => m.Category == ModCategory.Overrides, out var overridesMod)) + return overridesMod.ModId; + + var newOverrides = new Mod.New(tx) + { + LoadoutId = loadout, + Category = ModCategory.Overrides, + Name = "Overrides", + Enabled = true, + Status = ModStatus.Installed, + Revision = 0, + }; + return newOverrides.ModId; } /// - public async ValueTask DiskToFileTree(DiskStateTree diskState, Loadout.ReadOnly prevLoadout, FileTree prevFileTree, DiskStateTree prevDiskState) + public SyncTree BuildSyncTree(DiskStateTree currentState, DiskStateTree previousTree, Loadout.ReadOnly loadoutTree) { - List> results = new(); - var newFiles = new List(); - foreach (var item in diskState.GetAllDescendentFiles()) + var tree = new Dictionary(); + + var grouped = loadoutTree.Mods.Where(m => m.Enabled) + .SelectMany(m => m.Files) + .GroupBy(f => f.To); + + foreach (var group in grouped) { - var gamePath = item.GamePath(); - var absPath = prevLoadout.InstallationInstance.LocationsRegister.GetResolvedPath(item.GamePath()); - if (prevDiskState.TryGetValue(gamePath, out var prevEntry)) + var path = group.Key; + var file = group.First(); + if (group.Count() > 1) { - var prevFile = prevFileTree[gamePath].Item.Value!; - if (prevEntry.Item.Value.Hash == item.Item.Value.Hash) - { - // If the file hasn't changed, use it as-is - results.Add(KeyValuePair.Create(gamePath, prevFile)); - continue; - } - - // Else, the file has changed, so we need to update it. - var newFile = await HandleChangedFile(prevFile, prevEntry.Item.Value, item.Item.Value, gamePath, absPath); - newFiles.Add(newFile); + file = SelectWinningFile(group); } - else + + // Deleted file markers are not included in the sync tree + if (file.TryGetAsDeletedFile(out _)) + continue; + + if (!file.TryGetAsStoredFile(out var stored)) { - // Else, the file is new, so we need to add it. - var newFile = await HandleNewFile(item.Item.Value, gamePath, absPath); - newFiles.Add(newFile); + _logger.LogWarning("File {Path} is not a stored file, skipping", path); + continue; } + + tree.Add(path, new SyncTreeNode + { + Path = path, + LoadoutFile = stored, + }); } - if (newFiles.Count > 0) + foreach (var node in previousTree.GetAllDescendentFiles()) { - _logger.LogInformation("Found {Count} new files during ingest", newFiles.Count); - - ModId overridesModId; - using var tx = Connection.BeginTransaction(); - - if (!prevLoadout.Mods.TryGetFirst(m => m.Category == ModCategory.Overrides, out var overridesMod)) + if (tree.TryGetValue(node.GamePath(), out var found)) { - var newOverrideMod = new Mod.New(tx) - { - LoadoutId = prevLoadout, - Category = ModCategory.Overrides, - Name = "Overrides", - Enabled = true, - Revision = 0, - Status = ModStatus.Installed, - }; - overridesModId = newOverrideMod.Id; + found.Previous = node.Item.Value; } else { - overridesModId = overridesMod.Id; + tree.Add(node.GamePath(), new SyncTreeNode + { + Path = node.GamePath(), + Previous = node.Item.Value, + }); } - - List addedFiles = []; - foreach (var newFile in newFiles) + } + + foreach (var node in currentState.GetAllDescendentFiles()) + { + if (tree.TryGetValue(node.GamePath(), out var found)) { - // NOTE(erri120): allow implementations to put new files into custom mods - // but default to the override mod if they don't - if (!newFile.Contains(File.Loadout) && !newFile.Contains(File.Mod)) - { - newFile.Add(File.Loadout, prevLoadout.Id); - newFile.Add(File.Mod, overridesModId); - } - - newFile.AddTo(tx); - addedFiles.Add(newFile.Id!.Value); + found.Disk = node.Item.Value; } - - var result = await tx.Commit(); - - foreach (var addedFile in addedFiles) + else { - var storedFile = StoredFile.Load(result.Db, result[addedFile]); - results.Add(KeyValuePair.Create(storedFile.AsFile().To, storedFile.AsFile())); + tree.Add(node.GamePath(), new SyncTreeNode + { + Path = node.GamePath(), + Disk = node.Item.Value, + }); } } - - return FileTree.Create(results); - } - - /// - /// When a file is new, this method will be called to convert the new data into a AModFile. The file contents - /// are still accessible via - /// - /// - /// - /// - /// - /// - /// An unpersisted new file. This file needs to be persisted. - protected virtual ValueTask HandleNewFile(DiskStateEntry newEntry, GamePath gamePath, AbsolutePath absolutePath) - { - var newFile = new TempEntity - { - {StoredFile.Hash, newEntry.Hash}, - {StoredFile.Size, newEntry.Size}, - {File.To, gamePath}, - }; - return ValueTask.FromResult(newFile); + + return new SyncTree(tree); } - - /// - /// When a file is changed, this method will be called to convert the new data into a AModFile. The - /// file on disk is still accessible via - /// - protected virtual async ValueTask HandleChangedFile(File.ReadOnly prevFile, DiskStateEntry prevEntry, DiskStateEntry newEntry, GamePath gamePath, AbsolutePath absolutePath) + /// + public async Task BuildSyncTree(Loadout.ReadOnly loadout) { - var newFile = new TempEntity - { - {StoredFile.Hash, newEntry.Hash}, - {StoredFile.Size, newEntry.Size}, - {File.To, gamePath}, - }; - return newFile; + var diskState = await GetDiskState(loadout.InstallationInstance); + var prevDiskState = _diskStateRegistry.GetState(loadout.InstallationInstance)!; + + return BuildSyncTree(diskState, prevDiskState, loadout); } /// - public async ValueTask FileTreeToFlattenedLoadout(FileTree fileTree, Loadout.ReadOnly prevLoadout, - FlattenedLoadout prevFlattenedLoadout) + public SyncActionGroupings ProcessSyncTree(SyncTree tree) { - var resultIds = new List<(GamePath Path, EntityId Id)>(); - var results = new List>(); - var mods = prevLoadout.Mods - .GroupBy(m => m.Category) - .ToDictionary(g => g.Key, g => g.First().Id); + var groupings = new SyncActionGroupings(); - using var tx = Connection.BeginTransaction(); - - // Helper function to get a mod for a given category, or create a new one if it doesn't exist. - EntityId ModForCategory(ModCategory category) + foreach (var entry in tree.GetAllDescendentFiles()) { - if (mods.TryGetValue(category, out var mod)) - return mod; - var newMod = new Mod.New(tx) + var item = entry.Item.Value; + + var signature = new SignatureBuilder { - Category = category, - Name = category.ToString(), - Enabled = true, - Revision = 0, - LoadoutId = prevLoadout.LoadoutId, - Status = ModStatus.Installed, - }; - mods.Add(category, newMod); - return newMod; + DiskHash = item.Disk.HasValue ? item.Disk.Value.Hash : Optional.None, + PrevHash = item.Previous.HasValue ? item.Previous.Value.Hash : Optional.None, + LoadoutHash = item.LoadoutFile.HasValue ? item.LoadoutFile.Value.Hash : Optional.None, + DiskArchived = item.Disk.HasValue && HaveArchive(item.Disk.Value.Hash), + PrevArchived = item.Previous.HasValue && HaveArchive(item.Previous.Value.Hash), + LoadoutArchived = item.LoadoutFile.HasValue && HaveArchive(item.LoadoutFile.Value.Hash), + PathIsIgnored = false, + }.Build(); + + item.Signature = signature; + item.Actions = ActionMapping.MapActions(signature); + + groupings.Add(item); } + return groupings; + } + + /// + public async Task RunGroupings(SyncTree tree, SyncActionGroupings groupings, Loadout.ReadOnly loadout) + { + + var previousTree = _diskStateRegistry.GetState(loadout.InstallationInstance)! + .GetAllDescendentFiles() + .ToDictionary(d => d.Item.GamePath, d => d.Item.Value); - // Find all the files, and try to find a match in the previous state - foreach (var item in fileTree.GetAllDescendentFiles()) + foreach (var action in ActionsInOrder) { - resultIds.Add((item.Item.GamePath, item.Item.Value.Id)); - // No mod, so we need to put it somewhere, for now we put it in the Override Mod - if (!item.Item.Value.Contains(File.Mod)) + var items = groupings[action]; + if (items.Count == 0) + continue; + + var register = loadout.InstallationInstance.LocationsRegister; + + + switch (action) { - var modId = ModForCategory(ModCategory.Overrides); - tx.Add(item.Item.Value.Id, File.Mod, modId); - } - } - var result = await tx.Commit(); + case Actions.DoNothing: + break; - var tree = resultIds.Select(kv => - KeyValuePair.Create(kv.Path, File.ReadOnly.Create(result.Db, result[kv.Id]))); - return FlattenedLoadout.Create(tree); - } + case Actions.BackupFile: + await ActionBackupFiles(groupings, loadout); + break; -#endregion + case Actions.IngestFromDisk: + loadout = await ActionIngestFromDisk(groupings, loadout, previousTree); + break; + + case Actions.DeleteFromDisk: + ActionDeleteFromDisk(groupings, register, previousTree); + break; + + case Actions.ExtractToDisk: + await ActionExtractToDisk(groupings, register, previousTree); + break; - #region ILoadoutSynchronizer Implementation + case Actions.AddReifiedDelete: + loadout = await ActionAddReifiedDelete(groupings, loadout, previousTree); + break; + + case Actions.WarnOfUnableToExtract: + WarnOfUnableToExtract(groupings); + break; + + case Actions.WarnOfConflict: + WarnOfConflict(groupings); + break; + + default: + throw new InvalidOperationException($"Unknown action: {action}"); - /// - /// Applies a loadout to the game folder. - /// - /// - /// - /// Skips checking if an ingest is needed. - /// Force overrides current locations to intended tree - /// - /// - public virtual async Task Apply(Loadout.ReadOnly loadout, bool forceSkipIngest = false) - { - // Note(Sewer) If the last loadout was a vanilla state loadout, we need to - // skip ingest and ignore changes, since vanilla state loadouts should not be changed. - // (Prevent 'Needs Ingest' exception) - forceSkipIngest = forceSkipIngest || IsLastLoadoutAVanillaStateLoadout(loadout.InstallationInstance); - - var flattened = await LoadoutToFlattenedLoadout(loadout); - var fileTree = await FlattenedLoadoutToFileTree(flattened, loadout); - var prevState = _diskStateRegistry.GetState(loadout.InstallationInstance)!; - var diskState = await FileTreeToDisk(fileTree, loadout, flattened, prevState, loadout.InstallationInstance, forceSkipIngest); - diskState.LoadoutId = loadout.Id; - diskState.TxId = loadout.MostRecentTxId(); - await _diskStateRegistry.SaveState(loadout.InstallationInstance, diskState); - - return diskState; - } + } + } + + var newTree = DiskStateTree.Create(previousTree); + newTree.LoadoutId = loadout.Id; + newTree.TxId = loadout.MostRecentTxId(); - /// - public virtual async Task Ingest(Loadout.ReadOnly loadout) - { - // Reconstruct the previous file tree - var prevFlattenedLoadout = await LoadoutToFlattenedLoadout(loadout); - var prevFileTree = await FlattenedLoadoutToFileTree(prevFlattenedLoadout, loadout); - var prevDiskState = _diskStateRegistry.GetState(loadout.InstallationInstance)!; + // Clean up empty directories + var deletedFiles = groupings[Actions.DeleteFromDisk]; + if (deletedFiles.Count > 0) + { + CleanDirectories(deletedFiles.Select(f => f.Path), newTree, loadout.InstallationInstance); + } + + await _diskStateRegistry.SaveState(loadout.InstallationInstance, newTree); - // Get the new disk state - var diskState = await GetDiskState(loadout.InstallationInstance); - var newFiles = FindChangedFiles(diskState, loadout, prevFileTree, prevDiskState); + return loadout; + } - await BackupNewFiles(loadout.InstallationInstance, newFiles.Select(f => - (f.GetFirst(File.To), - f.GetFirst(StoredFile.Hash), - f.GetFirst(StoredFile.Size)))); - var newLoadout = await AddChangedFilesToLoadout(loadout, newFiles); + private void WarnOfConflict(SyncActionGroupings groupings) + { + var conflicts = groupings[Actions.WarnOfConflict]; + _logger.LogWarning("Conflict detected in {Count} files", conflicts.Count); - diskState.LoadoutId = newLoadout.Id; - diskState.TxId = newLoadout.MostRecentTxId(); - await _diskStateRegistry.SaveState(loadout.InstallationInstance, diskState); - return newLoadout; + foreach (var item in conflicts) + { + _logger.LogWarning("Conflict in {Path}", item.Path); + } } - protected ModId GetOrCreateOverridesMod(Loadout.ReadOnly loadout, ITransaction tx) + private void WarnOfUnableToExtract(SyncActionGroupings groupings) { - if (loadout.Mods.TryGetFirst(m => m.Category == ModCategory.Overrides, out var overridesMod)) - return overridesMod.ModId; + var unableToExtract = groupings[Actions.WarnOfUnableToExtract]; + _logger.LogWarning("Unable to extract {Count} files", unableToExtract.Count); - var newOverrides = new Mod.New(tx) + foreach (var item in unableToExtract) { - LoadoutId = loadout, - Category = ModCategory.Overrides, - Name = "Overrides", - Enabled = true, - Status = ModStatus.Installed, - Revision = 0, - }; - return newOverrides.ModId; + _logger.LogWarning("Unable to extract {Path}", item.Path); + } } - protected virtual async Task AddChangedFilesToLoadout(Loadout.ReadOnly loadout, TempEntity[] newFiles) + private async Task ActionAddReifiedDelete(SyncActionGroupings groupings, Loadout.ReadOnly loadout, Dictionary previousTree) { + var toAddDelete = groupings[Actions.AddReifiedDelete]; + _logger.LogDebug("Adding {Count} reified deletes", toAddDelete.Count); + using var tx = Connection.BeginTransaction(); var overridesMod = GetOrCreateOverridesMod(loadout, tx); - foreach (var newFile in newFiles) + foreach (var item in toAddDelete) { - newFile.Add(File.Loadout, loadout.Id); - newFile.Add(File.Mod, overridesMod); - newFile.AddTo(tx); + var delete = new DeletedFile.New(tx) + { + File = new File.New(tx) + { + To = item.Path, + ModId = overridesMod, + LoadoutId = loadout.Id, + }, + Size = item.LoadoutFile.Value.Size, + }; + + previousTree.Remove(item.Path); } - - // If we created the mod in this transaction, db will be null, and we can't call .Revise on it - // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (overridesMod.Value.InPartition(PartitionId.Temp)) { var mod = new Mod.ReadOnly(loadout.Db, overridesMod); @@ -642,237 +378,237 @@ protected ModId GetOrCreateOverridesMod(Loadout.ReadOnly loadout, ITransaction t { loadout.Revise(tx); } - - var result = await tx.Commit(); - return loadout.Rebase(result.Db); + + await tx.Commit(); + return loadout.Rebase(); } - private TempEntity[] FindChangedFiles(DiskStateTree diskState, Loadout.ReadOnly loadout, FileTree prevFileTree, DiskStateTree prevDiskState) + private async Task ActionExtractToDisk(SyncActionGroupings groupings, IGameLocationsRegister register, Dictionary previousTree) { - List newFiles = new(); - // Find files on disk that have changed or are not in the loadout - foreach (var file in diskState.GetAllDescendentFiles()) + // Extract files to disk + var toExtract = groupings[Actions.ExtractToDisk]; + _logger.LogDebug("Extracting {Count} files to disk", toExtract.Count); + if (toExtract.Count > 0) { - var gamePath = file.GamePath(); - TempEntity? newFile; - if (prevDiskState.TryGetValue(gamePath, out var prevEntry)) + await _fileStore.ExtractFiles(toExtract.Select(item => { - var prevFile = prevFileTree[gamePath].Item.Value!; - if (prevEntry.Item.Value.Hash == file.Item.Value.Hash) - { - // If the file hasn't changed, do nothing - continue; - } + var gamePath = register.GetResolvedPath(item.Path); + return (item.LoadoutFile.Value.Hash, gamePath); + }), CancellationToken.None); - if (prevFile.Mod.Category == ModCategory.Overrides) - { - // Previous file was in the overrides, so we need to update it - newFile = new TempEntity - { - { StoredFile.Hash, file.Item.Value.Hash }, - { StoredFile.Size, file.Item.Value.Size }, - }; - newFile.Id = prevFile.Id; - } - else - { - // Previous file was not in the overrides, so we need to add it - newFile = new TempEntity - { - { StoredFile.Hash, file.Item.Value.Hash }, - { StoredFile.Size, file.Item.Value.Size }, - { File.To, gamePath }, - }; - } - } - else + var isUnix = _os.IsUnix(); + foreach (var entry in toExtract) { - // Else, the file is new, so we need to add it. - newFile = new TempEntity + previousTree[entry.Path] = new DiskStateEntry { - { StoredFile.Hash, file.Item.Value.Hash }, - { StoredFile.Size, file.Item.Value.Size }, - { File.To, gamePath }, + Hash = entry.LoadoutFile.Value.Hash, + Size = entry.LoadoutFile.Value.Size, + // TODO: this isn't needed and we can delete it eventually + LastModified = DateTime.UtcNow, }; + + + + // And mark them as executable if necessary, on Unix + if (!isUnix) + continue; + + var path = register.GetResolvedPath(entry.Path); + var ext = path.Extension.ToString().ToLower(); + if (ext is not ("" or ".sh" or ".bin" or ".run" or ".py" or ".pl" or ".php" or ".rb" or ".out" + or ".elf")) continue; + + // Note (Sewer): I don't think we'd ever need anything other than just 'user' execute, but you can never + // be sure. Just in case, I'll throw in group and other to match 'chmod +x' behaviour. + var currentMode = path.GetUnixFileMode(); + path.SetUnixFileMode(currentMode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); } + } + } - newFiles.Add(newFile); + private void ActionDeleteFromDisk(SyncActionGroupings groupings, IGameLocationsRegister register, Dictionary previousTree) + { + // Delete files from disk + var toDelete = groupings[Actions.DeleteFromDisk]; + _logger.LogDebug("Deleting {Count} files from disk", toDelete.Count); + foreach (var item in toDelete) + { + var gamePath = register.GetResolvedPath(item.Path); + gamePath.Delete(); + previousTree.Remove(item.Path); } - - // Find files in the loadout that are not on disk - foreach (var loadoutFile in prevDiskState.GetAllDescendentFiles()) + } + + private async Task ActionBackupFiles(SyncActionGroupings groupings, Loadout.ReadOnly loadout) + { + var toBackup = groupings[Actions.BackupFile]; + _logger.LogDebug("Backing up {Count} files", toBackup.Count); + + await BackupNewFiles(loadout.InstallationInstance, toBackup.Select(item => + (item.Path, item.Disk.Value.Hash, item.Disk.Value.Size))); + } + + private async Task ActionIngestFromDisk(SyncActionGroupings groupings, Loadout.ReadOnly loadout, Dictionary previousTree) + { + var toIngest = groupings[Actions.IngestFromDisk]; + _logger.LogDebug("Ingesting {Count} files", toIngest.Count); + using var tx = Connection.BeginTransaction(); + var overridesMod = GetOrCreateOverridesMod(loadout, tx); + + var added = new List(); + + foreach (var file in toIngest) { - var gamePath = loadoutFile.GamePath(); - if (diskState.ContainsKey(gamePath)) - continue; - - var prevDbFile = prevFileTree[gamePath].Item.Value!; - if (prevDbFile.Mod.Category == ModCategory.Overrides) + var storedFile = new StoredFile.New(tx) { - if (prevDbFile.TryGetAsDeletedFile(out _)) - { - // already deleted, do nothing - continue; - } - else - { - var newFile = new TempEntity - { - { StoredFile.Hash, loadoutFile.Item.Value.Hash }, - { StoredFile.Size, loadoutFile.Item.Value.Size }, - { DeletedFile.Size, loadoutFile.Item.Value.Size }, - { File.To, gamePath }, - }; - newFile.Id = prevDbFile.Id; - newFiles.Add(newFile); - } - } - else - { - // New Deleted file - var newFile = new TempEntity + File = new File.New(tx) { - { StoredFile.Hash, loadoutFile.Item.Value.Hash }, - { StoredFile.Size, loadoutFile.Item.Value.Size }, - { DeletedFile.Size, loadoutFile.Item.Value.Size }, - { File.To, gamePath }, - }; - newFiles.Add(newFile); - } + To = file.Path, + ModId = overridesMod, + LoadoutId = loadout.Id, + }, + Hash = file.Disk.Value.Hash, + Size = file.Disk.Value.Size, + }; + added.Add(storedFile); + previousTree[file.Path] = file.Disk.Value with { LastModified = DateTime.UtcNow }; } - + + if (overridesMod.Value.InPartition(PartitionId.Temp)) + { + var mod = new Mod.ReadOnly(loadout.Db, overridesMod); + mod.Revise(tx); + } + else + { + loadout.Revise(tx); + } + + var result = await tx.Commit(); + + loadout = loadout.Rebase(); - return newFiles.ToArray(); + if (added.Count > 0) + loadout = await MoveNewFilesToMods(loadout, added.Select(file => result.Remap(file)).ToArray()); + return loadout; } /// - public async ValueTask LoadoutToDiskDiff(Loadout.ReadOnly loadout, DiskStateTree diskState) + public async Task Synchronize(Loadout.ReadOnly loadout) + { + var prevDiskState = _diskStateRegistry.GetState(loadout.InstallationInstance)!; + + // If we are swapping loadouts, then we need to synchronize the previous loadout first to ingest + // any changes, then we can apply the new loadout. + if (prevDiskState.LoadoutId != loadout.Id) + { + var prevLoadout = Loadout.Load(loadout.Db, prevDiskState.LoadoutId); + if (prevLoadout.IsValid()) + await Synchronize(prevLoadout); + } + + var tree = await BuildSyncTree(loadout); + var groupings = ProcessSyncTree(tree); + return await RunGroupings(tree, groupings, loadout); + } + + /// + /// All actions, in execution order. + /// + private static readonly Actions[] ActionsInOrder = Enum.GetValues().OrderBy(a => (ushort)a).ToArray(); + + protected bool HaveArchive(Hash hash) { - var flattenedLoadout = await LoadoutToFlattenedLoadout(loadout); - return await FlattenedLoadoutToDiskDiff(flattenedLoadout, diskState); + return _fileStore.HaveFile(hash).Result; } - private ValueTask FlattenedLoadoutToDiskDiff(FlattenedLoadout flattenedLoadout, DiskStateTree diskState) + /// + /// Given a list of files with duplicate game paths, select the winning file that will be applied to disk. + /// + /// + /// + protected virtual File.ReadOnly SelectWinningFile(IEnumerable files) { - var loadoutFiles = flattenedLoadout.GetAllDescendentFiles().ToArray(); - var diskStateEntries = diskState.GetAllDescendentFiles().ToArray(); + return files.MaxBy(f => (byte)f.Mod.Category); + } - // With both deletions and additions it might be more than Max, but it's a starting point - Dictionary resultingItems = new(Math.Max(loadoutFiles.Length, diskStateEntries.Length)); + /// + /// When new files are added to the loadout from disk, this method will be called to move the files from the override mod + /// into any other mod they may belong to. + /// + /// + /// + /// + protected virtual async Task MoveNewFilesToMods(Loadout.ReadOnly loadout, StoredFile.ReadOnly[] newFiles) + { + return loadout; + } + + /// + public FileDiffTree LoadoutToDiskDiff(Loadout.ReadOnly loadout, DiskStateTree diskState) + { + var syncTree = BuildSyncTree(diskState, diskState, loadout); + // Process the sync tree to get the actions populated in the nodes + ProcessSyncTree(syncTree); - // Add all the disk state entries to the result, checking for changes - foreach (var diskItem in diskStateEntries) + List diffs = new(); + + foreach (var node in syncTree.GetAllDescendentFiles()) { - var gamePath = diskItem.GamePath(); - if (flattenedLoadout.TryGetValue(gamePath, out var loadoutFileEntry)) + var syncNode = node.Item.Value; + var actions = syncNode.Actions; + + if (actions.HasFlag(Actions.DoNothing)) { - var file = loadoutFileEntry.Item.Value; - if (file.TryGetAsStoredFile(out var sf)) - { - if (file.TryGetAsDeletedFile(out _)) - { - resultingItems.Add(gamePath, - new DiskDiffEntry - { - GamePath = gamePath, - Hash = diskItem.Item.Value.Hash, - Size = diskItem.Item.Value.Size, - ChangeType = FileChangeType.Removed, - } - ); - continue; - } - - if (sf.Hash != diskItem.Item.Value.Hash) - { - resultingItems.Add(gamePath, - new DiskDiffEntry - { - GamePath = gamePath, - Hash = sf.Hash, - Size = sf.Size, - ChangeType = FileChangeType.Modified, - } - ); - } - else - { - resultingItems.Add(gamePath, - new DiskDiffEntry - { - GamePath = gamePath, - Hash = sf.Hash, - Size = sf.Size, - ChangeType = FileChangeType.None, - } - ); - } - } - else if (file.TryGetAsGeneratedFile(out _)) + var entry = new DiskDiffEntry { - // TODO: Implement change detection for generated files - continue; - } - else - { - throw new NotImplementedException("No way to handle this file"); - } - } - else - { - resultingItems.Add(gamePath, - new DiskDiffEntry - { - GamePath = gamePath, - Hash = diskItem.Item.Value.Hash, - Size = diskItem.Item.Value.Size, - ChangeType = FileChangeType.Removed, - } - ); + Hash = syncNode.LoadoutFile.Value.Hash, + Size = syncNode.LoadoutFile.Value.Size, + ChangeType = FileChangeType.None, + GamePath = node.GamePath(), + }; + diffs.Add(entry); } - } - - // Add all the new files to the result - foreach (var loadoutFile in loadoutFiles) - { - var gamePath = loadoutFile.GamePath(); - var file = loadoutFile.Item.Value; - if (file.TryGetAsStoredFile(out var storedFile)) + else if (actions.HasFlag(Actions.ExtractToDisk)) { - if (resultingItems.TryGetValue(gamePath, out _)) + var entry = new DiskDiffEntry { - continue; - } - - if (file.TryGetAsDeletedFile(out _)) - { - continue; - } - - resultingItems.Add(gamePath, - new DiskDiffEntry - { - GamePath = gamePath, - Hash = storedFile.Hash, - Size = storedFile.Size, - ChangeType = FileChangeType.Added, - } - ); + Hash = syncNode.LoadoutFile.Value.Hash, + Size = syncNode.LoadoutFile.Value.Size, + // If paired with a delete action, this is a modified file not a new one + ChangeType = actions.HasFlag(Actions.DeleteFromDisk) ? FileChangeType.Modified : FileChangeType.Added, + GamePath = node.GamePath(), + }; + diffs.Add(entry); } - else if (file.TryGetAsGeneratedFile(out _)) + else if (actions.HasFlag(Actions.DeleteFromDisk)) { - // TODO: Implement change detection for generated files - continue; + var entry = new DiskDiffEntry + { + Hash = syncNode.Disk.Value.Hash, + Size = syncNode.Disk.Value.Size, + ChangeType = FileChangeType.Removed, + GamePath = node.GamePath(), + }; + diffs.Add(entry); } else { - throw new NotImplementedException("No way to handle this file"); + // This really should become some sort of error state + var entry = new DiskDiffEntry + { + Hash = Hash.Zero, + Size = Size.Zero, + ChangeType = FileChangeType.None, + GamePath = node.GamePath(), + }; + diffs.Add(entry); } } - - return ValueTask.FromResult(FileDiffTree.Create(resultingItems)); + + return FileDiffTree.Create(diffs.Select(d => KeyValuePair.Create(d.GamePath, d))); } - + /// /// Backs up any new files in the loadout. /// @@ -913,19 +649,6 @@ await Parallel.ForEachAsync(files, async (file, _) => await _fileStore.BackupFiles(archivedFiles); } - /// - public async Task WriteGeneratedFile(File.ReadOnly file, Stream outputStream, Loadout.ReadOnly loadout, FlattenedLoadout flattenedLoadout, FileTree fileTree) - { - if (!file.TryGetAsGeneratedFile(out var generatedFile)) - throw new InvalidOperationException("File is not a generated file"); - - var generator = generatedFile.GeneratorInstance; - if (generator == null) - throw new InvalidOperationException("Generated file does not have a generator"); - - return await generator.Write(generatedFile, outputStream, loadout, flattenedLoadout, fileTree); - } - /// public virtual async Task CreateLoadout(GameInstallation installation, string? suggestedName = null) { @@ -993,25 +716,11 @@ await Parallel.ForEachAsync(files, async (file, _) => // Reset the game folder to initial state if making a new loadout. // We must do this before saving state, as Apply does a diff against // the last state. Which will be a state from previous loadout. - // Note(sewer): We can't just apply the new loadout here because we haven't ran SaveState + // Note(sewer): We can't just apply the new loadout here because we haven't run SaveState // and we can't guarantee we have a clean state without applying. if (isCached) { - // This is a 'fast apply' operation, that avoids recomputing the file tree. - // And avoids a double save-state. - var flattened = await LoadoutToFlattenedLoadout(remappedLoadout); - var prevState = _diskStateRegistry.GetState(remappedLoadout.InstallationInstance)!; - - var tree = FileTree.Create(allStoredFileModels.Select(file => - { - var remapped = result.Remap(file); - return KeyValuePair.Create(remapped.AsFile().To, remapped.AsFile()); - } - )); - await FileTreeToDisk(tree, remappedLoadout, flattened, prevState, remappedLoadout.InstallationInstance, true); - - // Note: DiskState returned from `FileTreeToDisk` and `initialState` - // are the same in terms of content!! + await Synchronize(remappedLoadout); } await _diskStateRegistry.SaveState(remappedLoadout.InstallationInstance, initialState); @@ -1158,48 +867,7 @@ a valid loadout. return result.Remap(loadout); } #endregion - - #region FlattenLoadoutToTree Methods - - /// - /// Returns the sort rules for a given mod, the loadout is given for context. - /// - /// - /// - /// - /// - public virtual async ValueTask[]> ModSortRules(Loadout.ReadOnly loadout, Mod.ReadOnly mod) - { - if (mod.Category == ModCategory.GameFiles) - return [new First()]; - if (mod.Category == ModCategory.Overrides) - return [new Last()]; - if (Mod.SortAfter.TryGet(mod, out var other)) - if (other != EntityId.From(0)) - return [new After { Other = ModId.From(other) }]; - return []; - } - - /// - /// Sorts the mods in a loadout. - /// - /// - /// - protected virtual async Task> SortMods(Loadout.ReadOnly loadout) - { - var mods = loadout.Mods.Where(mod => mod.Enabled).ToList(); - _logger.LogInformation("Sorting {ModCount} mods in loadout {LoadoutName}", mods.Count, loadout.Name); - var modRules = await mods - .SelectAsync(async mod => (mod.Id, await ModSortRules(loadout, mod))) - .ToDictionaryAsync(r => r.Id, r => r.Item2); - if (modRules.Count == 0) - return Array.Empty(); - - var sorted = _sorter.Sort(mods, m => ModId.From(m.Id), m => modRules[m.Id]); - return sorted; - } - #endregion - + #region Misc Helper Functions /// /// Checks if the last applied loadout is a 'vanilla state' loadout. @@ -1216,7 +884,7 @@ private bool IsLastLoadoutAVanillaStateLoadout(GameInstallation installation) } /// - /// By default this method just returns the current state of the game folders. Most of the time + /// By default, this method just returns the current state of the game folders. Most of the time /// this creates a sub-par user experience as users may have installed mods in the past and then /// these files will be marked as part of the game files when they are not. Properly implemented /// games should override this method and return only the files that are part of the game itself. @@ -1252,7 +920,7 @@ private async Task ApplyVanillaStateLoadout(GameInstallation installation) if (!vanillaStateLoadout.IsValid()) await CreateVanillaStateLoadout(installation); - await Apply(vanillaStateLoadout, true); + await Synchronize(vanillaStateLoadout); } #endregion diff --git a/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/FileTree.cs b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/FileTree.cs index 793c9c28e7..65b889538e 100644 --- a/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/FileTree.cs +++ b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/FileTree.cs @@ -1,10 +1,8 @@ -using NexusMods.Abstractions.GameLocators.Trees; -using NexusMods.Abstractions.Loadouts.Files; -using NexusMods.Hashing.xxHash64; -using NexusMods.Paths; +using NexusMods.Abstractions.GameLocators; +using NexusMods.Abstractions.GameLocators.Trees; using File = NexusMods.Abstractions.Loadouts.Files.File; -namespace NexusMods.Abstractions.GameLocators; +namespace NexusMods.Abstractions.Loadouts.Synchronizers; /// /// A file tree is a tree that contains all files from a loadout, flattened into a single tree. diff --git a/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/ILoadoutSynchronizer.cs b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/ILoadoutSynchronizer.cs index 8491a14220..859f229341 100644 --- a/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/ILoadoutSynchronizer.cs +++ b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/ILoadoutSynchronizer.cs @@ -10,37 +10,63 @@ namespace NexusMods.Abstractions.Loadouts.Synchronizers; public interface ILoadoutSynchronizer { - #region Diff Methods + #region Synchronization Methods /// - /// Computes the difference between a loadout and a disk state, assuming the loadout to be the newer state. + /// Creates a new sync tree from the current state of the game folder, the loadout and the previous state. This + /// sync tree contains a matching of all the files in all 3 sources based on their path. /// - /// Newer state, e.g. unapplied loadout - /// The old state, e.g. last applied DiskState - /// A tree of all the files with associated - ValueTask LoadoutToDiskDiff(Loadout.ReadOnly loadout, DiskStateTree diskState); + SyncTree BuildSyncTree(DiskStateTree currentState, DiskStateTree previousTree, Loadout.ReadOnly loadoutTree); - #endregion - - #region High Level Methods /// - /// Applies a loadout to the game folder. + /// Builds a sync tree from a loadout and the current state of the game folder. /// - /// The loadout to apply. - /// - /// Skips checking if an ingest is needed. - /// Force overrides current locations to intended tree - /// - /// The new DiskState after the files were applied - Task Apply(Loadout.ReadOnly loadout, bool forceSkipIngest = false); - + /// + /// + Task BuildSyncTree(Loadout.ReadOnly loadoutTree); + + /// + /// Processes the sync tree to create the signature and actions for each file, return a groupings object for the tree + /// + SyncActionGroupings ProcessSyncTree(SyncTree syncTree); + + /// + /// Run the groupings on the game folder and return a new loadout with the changes applied. + /// + Task RunGroupings(SyncTree tree, SyncActionGroupings groupings, Loadout.ReadOnly install); + + /// + /// Synchronizes the loadout with the game folder, any changes in the game folder will be added to the loadout, and any + /// new changes in the loadout will be applied to the game folder. + /// + Task Synchronize(Loadout.ReadOnly loadout); + /// - /// Finds changes from the game folder compared to loadout and bundles them - /// into 1 or more Mods. + /// Gets the current disk state of the game folders for the given game installation. /// - /// The current loadout. - Task Ingest(Loadout.ReadOnly loadout); + Task GetDiskState(GameInstallation installationInstance); + /// + /// Gets the current disk state of the game folders for the given loadout. + /// + Task GetDiskState(Loadout.ReadOnly loadout) + { + return GetDiskState(loadout.InstallationInstance); + } + + #endregion + + + #region High Level Methods + + /// + /// Computes the difference between a loadout and a disk state, assuming the loadout to be the newer state. + /// + /// Newer state, e.g. unapplied loadout + /// The old state, e.g. last applied DiskState + /// A tree of all the files with associated + FileDiffTree LoadoutToDiskDiff(Loadout.ReadOnly loadout, DiskStateTree diskState); + /// /// Creates a loadout for a game, managing the game if it has not previously /// been managed. @@ -67,7 +93,7 @@ public interface ILoadoutSynchronizer Task DeleteLoadout(GameInstallation installation, LoadoutId loadoutId); /// - /// Removes all of the loadouts for a game, an resets the game folder to its + /// Removes all the loadouts for a game, and resets the game folder to its /// initial state. /// /// Game installation which should be unmanaged. diff --git a/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/IStandardizedLoadoutSyncronizer.cs b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/IStandardizedLoadoutSyncronizer.cs deleted file mode 100644 index 63d862b114..0000000000 --- a/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/IStandardizedLoadoutSyncronizer.cs +++ /dev/null @@ -1,95 +0,0 @@ -using NexusMods.Abstractions.DiskState; -using NexusMods.Abstractions.GameLocators; -using NexusMods.Abstractions.Games.DTO; -using NexusMods.Hashing.xxHash64; -using NexusMods.Paths; - -using File = NexusMods.Abstractions.Loadouts.Files.File; - -namespace NexusMods.Abstractions.Loadouts.Synchronizers; - - -/// -/// Interface for a standardized loadout syncronizer, these are pulled out from ILoadoutSynchronizer to allow -/// keep the main interface cleaner. -/// -public interface IStandardizedLoadoutSynchronizer : ILoadoutSynchronizer -{ - #region Apply Methods - /// - /// Converts a loadout to a flattened loadout. - /// - /// - /// - ValueTask LoadoutToFlattenedLoadout(Loadout.ReadOnly loadout); - - /// - /// Converts a flattened loadout to a file tree. - /// - /// - /// - /// - ValueTask FlattenedLoadoutToFileTree(FlattenedLoadout flattenedLoadout, Loadout.ReadOnly loadout); - - /// - /// Writes a file tree to disk (updating the game files) - /// - /// - /// - /// - /// - /// - /// - /// Skips checking if an ingest is needed. - /// Force overrides current locations to intended tree. - /// - Task FileTreeToDisk(FileTree fileTree, Loadout.ReadOnly loadout, FlattenedLoadout flattenedLoadout, - DiskStateTree prevState, GameInstallation installation, bool skipIngest = false); - - #endregion - - #region Ingest Methods - - /// - /// Indexes the game folders and creates a disk state. - /// - /// - /// - Task GetDiskState(GameInstallation installation); - - /// - /// Creates a new file tree from the current disk state and the previous file tree. - /// - /// - /// - /// - /// - /// - ValueTask DiskToFileTree(DiskStateTree diskState, Loadout.ReadOnly prevLoadout, FileTree prevFileTree, DiskStateTree prevDiskState); - - /// - /// Creates a new flattened loadout from the current file tree and the previous flattened loadout. - /// - /// - /// - /// - /// - ValueTask FileTreeToFlattenedLoadout(FileTree fileTree, Loadout.ReadOnly prevLoadout, FlattenedLoadout prevFlattenedLoadout); - - - /// - /// Backs up any new files in the file tree. - /// - Task BackupNewFiles(GameInstallation installation, IEnumerable<(GamePath To, Hash Hash, Size Size)> fileTree); - #endregion - - #region File Helpers - - /// - /// Writes a generated file to the output stream. - /// - Task WriteGeneratedFile(File.ReadOnly file, Stream outputStream, Loadout.ReadOnly loadout, FlattenedLoadout flattenedLoadout, FileTree fileTree); - - -#endregion -} diff --git a/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/Rules/ActionMapping.cs b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/Rules/ActionMapping.cs new file mode 100644 index 0000000000..ee141bf602 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/Rules/ActionMapping.cs @@ -0,0 +1,130 @@ +using System.Diagnostics; +using static NexusMods.Abstractions.Loadouts.Synchronizers.Rules.Actions; +using static NexusMods.Abstractions.Loadouts.Synchronizers.Rules.SignatureShorthand; + +namespace NexusMods.Abstractions.Loadouts.Synchronizers.Rules; + +public class ActionMapping +{ + + /// + /// Maps a signature to the corresponding actions + /// + public static Actions MapActions(Signature signature) + { + return MapActions((SignatureShorthand)signature); + } + + /// + /// Maps a shorthand signature to the corresponding actions + /// + public static Actions MapActions(SignatureShorthand shorthand) + { + Debug.Assert(Enum.IsDefined(shorthand), $"Unknown value: {shorthand} ({(int)shorthand})"); + + // Format of the shorthand: + // xxx_yyy_z -> xxx: Loadout, yyy: Archive, z: Ignore path + // xxx: a tuple of `(Disk, Previous, Loadout)` states. + // A `x` means no state because that source has no value for the path. + // A `A`, `B`, `C` are placeholders for the hash of the file, so `AAA` means all three sources have the same hash, while `BAA` means the hash is different on disk + // from either the previous state or the loadout. + // yyy: a tuple of `(Disk, Previous, Loadout)` archive states. `XXX` means all three sources are archived (regardless of their hash) and `Xxx` means the disk is archived but the previous and loadout states are not. + // `z`: either `i` or `I`, where `i` means the path is not ignored and `I` means the path is ignored. + // The easiest way to think of this is that a capital letter means the existence of data, while a lowercase letter means the absence of data or a false value. + return shorthand switch + { + xxA_xxx_i => WarnOfUnableToExtract, + xxA_xxX_i => ExtractToDisk, + xxA_xxx_I => WarnOfUnableToExtract, + xxA_xxX_I => ExtractToDisk, + xAx_xxx_i => DoNothing, + xAx_xXx_i => DoNothing, + xAx_xxx_I => DoNothing, + xAx_xXx_I => DoNothing, + xAA_xxx_i => WarnOfUnableToExtract, + xAA_xXX_i => AddReifiedDelete, + xAA_xxx_I => AddReifiedDelete, + xAA_xXX_I => AddReifiedDelete, + xAB_xxx_i => WarnOfUnableToExtract, + xAB_xXx_i => WarnOfUnableToExtract, + xAB_xxX_i => ExtractToDisk, + xAB_xXX_i => ExtractToDisk, + xAB_xxx_I => WarnOfUnableToExtract, + xAB_xXx_I => WarnOfUnableToExtract, + xAB_xxX_I => ExtractToDisk, + xAB_xXX_I => ExtractToDisk, + Axx_xxx_i => BackupFile | IngestFromDisk, + Axx_Xxx_i => IngestFromDisk, + Axx_xxx_I => IngestFromDisk, + Axx_Xxx_I => IngestFromDisk, + AxA_xxx_i => DoNothing, + AxA_XxX_i => DoNothing, + AxA_xxx_I => DoNothing, + AxA_XxX_I => DoNothing, + AxB_xxx_i => BackupFile | IngestFromDisk, + AxB_Xxx_i => IngestFromDisk, + AxB_xxX_i => BackupFile | IngestFromDisk, + AxB_XxX_i => IngestFromDisk, + AxB_xxx_I => BackupFile | IngestFromDisk, + AxB_Xxx_I => BackupFile | IngestFromDisk, + AxB_xxX_I => BackupFile | DeleteFromDisk | ExtractToDisk, + AxB_XxX_I => BackupFile | DeleteFromDisk | ExtractToDisk, + AAx_xxx_i => BackupFile | DeleteFromDisk, + AAx_XXx_i => DeleteFromDisk, + AAx_xxx_I => BackupFile | DeleteFromDisk, + AAx_XXx_I => DeleteFromDisk, + AAA_xxx_i => BackupFile, + AAA_XXX_i => DoNothing, + AAA_xxx_I => DoNothing, + AAA_XXX_I => DoNothing, + AAB_xxx_i => WarnOfUnableToExtract, + AAB_XXx_i => WarnOfUnableToExtract, + AAB_xxX_i => BackupFile | DeleteFromDisk | ExtractToDisk, + AAB_XXX_i => DeleteFromDisk | ExtractToDisk, + AAB_xxx_I => WarnOfUnableToExtract, + AAB_XXx_I => WarnOfUnableToExtract, + AAB_xxX_I => BackupFile | DeleteFromDisk | ExtractToDisk, + AAB_XXX_I => DeleteFromDisk | ExtractToDisk, + ABx_xxx_i => BackupFile | DeleteFromDisk, + ABx_Xxx_i => DeleteFromDisk, + ABx_xXx_i => BackupFile | DeleteFromDisk, + ABx_XXx_i => DeleteFromDisk, + ABx_xxx_I => BackupFile | DeleteFromDisk, + ABx_Xxx_I => DeleteFromDisk, + ABx_xXx_I => BackupFile | DeleteFromDisk, + ABx_XXx_I => DeleteFromDisk, + ABA_xxx_i => BackupFile, + ABA_XxX_i => DoNothing, + ABA_xXx_i => BackupFile, + ABA_XXX_i => DoNothing, + ABA_xxx_I => DoNothing, + ABA_XxX_I => DoNothing, + ABA_xXx_I => DoNothing, + ABA_XXX_I => DoNothing, + ABB_xxx_i => BackupFile | IngestFromDisk, + ABB_Xxx_i => IngestFromDisk, + ABB_xXX_i => BackupFile | IngestFromDisk, + ABB_XXX_i => IngestFromDisk, + ABB_xxx_I => BackupFile | IngestFromDisk, + ABB_Xxx_I => IngestFromDisk, + ABB_xXX_I => BackupFile | IngestFromDisk, + ABB_XXX_I => IngestFromDisk, + ABC_xxx_i => WarnOfConflict, + ABC_Xxx_i => WarnOfUnableToExtract, + ABC_xXx_i => WarnOfConflict, + ABC_xxX_i => BackupFile | IngestFromDisk, + ABC_XXx_i => BackupFile | IngestFromDisk, + ABC_XxX_i => WarnOfConflict, + ABC_xXX_i => BackupFile | IngestFromDisk, + ABC_XXX_i => IngestFromDisk, + ABC_xxx_I => BackupFile | IngestFromDisk, + ABC_Xxx_I => WarnOfUnableToExtract, + ABC_xXx_I => WarnOfUnableToExtract, + ABC_xxX_I => BackupFile | DeleteFromDisk | ExtractToDisk, + ABC_XXx_I => WarnOfUnableToExtract, + ABC_XxX_I => DeleteFromDisk | ExtractToDisk, + ABC_xXX_I => BackupFile | DeleteFromDisk | ExtractToDisk, + ABC_XXX_I => DeleteFromDisk | ExtractToDisk + }; + } +} diff --git a/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/Rules/Actions.cs b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/Rules/Actions.cs new file mode 100644 index 0000000000..483f65075e --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/Rules/Actions.cs @@ -0,0 +1,45 @@ +namespace NexusMods.Abstractions.Loadouts.Synchronizers.Rules; + +[Flags] +public enum Actions : ushort +{ + /// + /// Do nothing, should not be combined with any other action + /// + DoNothing = 1, + + /// + /// Extracts the file specified in the loadout to disk, file should be previously deleted + /// + ExtractToDisk = 2, + + /// + /// Create a backup of the file + /// + BackupFile = 4, + + /// + /// Deletes the file from the loadout via adding a reified delete + /// + AddReifiedDelete = 8, + + /// + /// Updates the loadout to reference the new hash from the disk + /// + IngestFromDisk = 16, + + /// + /// A file would be extracted, but it's not archived, warn the user. + /// + WarnOfUnableToExtract = 32, + + /// + /// Deletes the file from disk + /// + DeleteFromDisk = 64, + + /// + /// Warn the user that the merge has failed and we don't know what to do with the file + /// + WarnOfConflict = 128, +} diff --git a/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/Rules/Signature.cs b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/Rules/Signature.cs new file mode 100644 index 0000000000..300bbd70b4 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/Rules/Signature.cs @@ -0,0 +1,146 @@ +using DynamicData.Kernel; +using NexusMods.Hashing.xxHash64; + +namespace NexusMods.Abstractions.Loadouts.Synchronizers.Rules; + +[Flags] +public enum Signature : ushort +{ + /// + /// Empty signature, used only as a way to detect an uninitialized signature. + /// + Empty = 0, + + /// + /// True if the file exists on disk. + /// + DiskExists = 1, + + /// + /// True if the file exists in the previous state. + /// + PrevExists = 2, + + /// + /// True if the file exists in the loadout. + /// + LoadoutExists = 4, + + /// + /// True if the hashes of the disk and previous state are equal. + /// + DiskEqualsPrev = 8, + + /// + /// True if the hashes of the previous state and loadout are equal. + /// + PrevEqualsLoadout = 16, + + /// + /// True if the hashes of the disk and loadout are equal. + /// + DiskEqualsLoadout = 32, + + /// + /// True if the file on disk is already archived. + /// + DiskArchived = 64, + + /// + /// True if the file in the previous state is archived. + /// + PrevArchived = 128, + + /// + /// True if the file in the loadout is archived. + /// + LoadoutArchived = 256, + + /// + /// True if the path is ignored, i.e. it is on a game-specific ignore list. + /// + PathIsIgnored = 512, +} + + +/// +/// A builder for creating a from its components, assign the properties and call to get the final . +/// +public readonly struct SignatureBuilder +{ + /// + /// The hash of the file on disk. + /// + public Optional DiskHash { get; init; } + + /// + /// The hash of the file in the previous state. + /// + public Optional PrevHash { get; init; } + + /// + /// The hash of the file in the loadout. + /// + public Optional LoadoutHash { get; init; } + + /// + /// True if the file on disk is already archived. + /// + public bool DiskArchived { get; init; } + + /// + /// True if the file in the previous state is archived. + /// + public bool PrevArchived { get; init; } + + /// + /// True if the file in the loadout is archived. + /// + public bool LoadoutArchived { get; init; } + + /// + /// True if the path is ignored, i.e. it is on a game-specific ignore list. + /// + public bool PathIsIgnored { get; init; } + + /// + /// Builds the final from the properties. + /// + /// + public Signature Build() + { + var sig = Signature.Empty; + + if (DiskHash.HasValue) + sig |= Signature.DiskExists; + + if (PrevHash.HasValue) + sig |= Signature.PrevExists; + + if (LoadoutHash.HasValue) + sig |= Signature.LoadoutExists; + + if (DiskHash.HasValue && PrevHash.HasValue && DiskHash.Value == PrevHash.Value) + sig |= Signature.DiskEqualsPrev; + + if (PrevHash.HasValue && LoadoutHash.HasValue && PrevHash.Value == LoadoutHash.Value) + sig |= Signature.PrevEqualsLoadout; + + if (DiskHash.HasValue && LoadoutHash.HasValue && DiskHash.Value == LoadoutHash.Value) + sig |= Signature.DiskEqualsLoadout; + + if (DiskArchived) + sig |= Signature.DiskArchived; + + if (PrevArchived) + sig |= Signature.PrevArchived; + + if (LoadoutArchived) + sig |= Signature.LoadoutArchived; + + if (PathIsIgnored) + sig |= Signature.PathIsIgnored; + + return sig; + } +} diff --git a/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/Rules/SignatureShorthand.cs b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/Rules/SignatureShorthand.cs new file mode 100644 index 0000000000..6e61e87194 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/Rules/SignatureShorthand.cs @@ -0,0 +1,377 @@ +// ReSharper disable InconsistentNaming +namespace NexusMods.Abstractions.Loadouts.Synchronizers.Rules; + +/// +/// Summary of all 92 possible signatures in a somewhat readable format +/// +public enum SignatureShorthand : ushort +{ + /// + /// LoadoutExists + /// + xxA_xxx_i = 0x0004, + /// + /// LoadoutExists, LoadoutArchived + /// + xxA_xxX_i = 0x0104, + /// + /// LoadoutExists, PathIsIgnored + /// + xxA_xxx_I = 0x0204, + /// + /// LoadoutExists, LoadoutArchived, PathIsIgnored + /// + xxA_xxX_I = 0x0304, + /// + /// PrevExists + /// + xAx_xxx_i = 0x0002, + /// + /// PrevExists, PrevArchived + /// + xAx_xXx_i = 0x0082, + /// + /// PrevExists, PathIsIgnored + /// + xAx_xxx_I = 0x0202, + /// + /// PrevExists, PrevArchived, PathIsIgnored + /// + xAx_xXx_I = 0x0282, + /// + /// PrevExists, LoadoutExists, PrevEqualsLoadout + /// + xAA_xxx_i = 0x0016, + /// + /// PrevExists, LoadoutExists, PrevEqualsLoadout, PrevArchived, LoadoutArchived + /// + xAA_xXX_i = 0x0196, + /// + /// PrevExists, LoadoutExists, PrevEqualsLoadout, PathIsIgnored + /// + xAA_xxx_I = 0x0216, + /// + /// PrevExists, LoadoutExists, PrevEqualsLoadout, PrevArchived, LoadoutArchived, PathIsIgnored + /// + xAA_xXX_I = 0x0396, + /// + /// PrevExists, LoadoutExists + /// + xAB_xxx_i = 0x0006, + /// + /// PrevExists, LoadoutExists, PrevArchived + /// + xAB_xXx_i = 0x0086, + /// + /// PrevExists, LoadoutExists, LoadoutArchived + /// + xAB_xxX_i = 0x0106, + /// + /// PrevExists, LoadoutExists, PrevArchived, LoadoutArchived + /// + xAB_xXX_i = 0x0186, + /// + /// PrevExists, LoadoutExists, PathIsIgnored + /// + xAB_xxx_I = 0x0206, + /// + /// PrevExists, LoadoutExists, PrevArchived, PathIsIgnored + /// + xAB_xXx_I = 0x0286, + /// + /// PrevExists, LoadoutExists, LoadoutArchived, PathIsIgnored + /// + xAB_xxX_I = 0x0306, + /// + /// PrevExists, LoadoutExists, PrevArchived, LoadoutArchived, PathIsIgnored + /// + xAB_xXX_I = 0x0386, + /// + /// DiskExists + /// + Axx_xxx_i = 0x0001, + /// + /// DiskExists, DiskArchived + /// + Axx_Xxx_i = 0x0041, + /// + /// DiskExists, PathIsIgnored + /// + Axx_xxx_I = 0x0201, + /// + /// DiskExists, DiskArchived, PathIsIgnored + /// + Axx_Xxx_I = 0x0241, + /// + /// DiskExists, LoadoutExists, DiskEqualsLoadout + /// + AxA_xxx_i = 0x0025, + /// + /// DiskExists, LoadoutExists, DiskEqualsLoadout, DiskArchived, LoadoutArchived + /// + AxA_XxX_i = 0x0165, + /// + /// DiskExists, LoadoutExists, DiskEqualsLoadout, PathIsIgnored + /// + AxA_xxx_I = 0x0225, + /// + /// DiskExists, LoadoutExists, DiskEqualsLoadout, DiskArchived, LoadoutArchived, PathIsIgnored + /// + AxA_XxX_I = 0x0365, + /// + /// DiskExists, LoadoutExists + /// + AxB_xxx_i = 0x0005, + /// + /// DiskExists, LoadoutExists, DiskArchived + /// + AxB_Xxx_i = 0x0045, + /// + /// DiskExists, LoadoutExists, LoadoutArchived + /// + AxB_xxX_i = 0x0105, + /// + /// DiskExists, LoadoutExists, DiskArchived, LoadoutArchived + /// + AxB_XxX_i = 0x0145, + /// + /// DiskExists, LoadoutExists, PathIsIgnored + /// + AxB_xxx_I = 0x0205, + /// + /// DiskExists, LoadoutExists, DiskArchived, PathIsIgnored + /// + AxB_Xxx_I = 0x0245, + /// + /// DiskExists, LoadoutExists, LoadoutArchived, PathIsIgnored + /// + AxB_xxX_I = 0x0305, + /// + /// DiskExists, LoadoutExists, DiskArchived, LoadoutArchived, PathIsIgnored + /// + AxB_XxX_I = 0x0345, + /// + /// DiskExists, PrevExists, DiskEqualsPrev + /// + AAx_xxx_i = 0x000B, + /// + /// DiskExists, PrevExists, DiskEqualsPrev, DiskArchived, PrevArchived + /// + AAx_XXx_i = 0x00CB, + /// + /// DiskExists, PrevExists, DiskEqualsPrev, PathIsIgnored + /// + AAx_xxx_I = 0x020B, + /// + /// DiskExists, PrevExists, DiskEqualsPrev, DiskArchived, PrevArchived, PathIsIgnored + /// + AAx_XXx_I = 0x02CB, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, PrevEqualsLoadout, DiskEqualsLoadout + /// + AAA_xxx_i = 0x003F, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, PrevEqualsLoadout, DiskEqualsLoadout, DiskArchived, PrevArchived, LoadoutArchived + /// + AAA_XXX_i = 0x01FF, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, PrevEqualsLoadout, DiskEqualsLoadout, PathIsIgnored + /// + AAA_xxx_I = 0x023F, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, PrevEqualsLoadout, DiskEqualsLoadout, DiskArchived, PrevArchived, LoadoutArchived, PathIsIgnored + /// + AAA_XXX_I = 0x03FF, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev + /// + AAB_xxx_i = 0x000F, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, DiskArchived, PrevArchived + /// + AAB_XXx_i = 0x00CF, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, LoadoutArchived + /// + AAB_xxX_i = 0x010F, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, DiskArchived, PrevArchived, LoadoutArchived + /// + AAB_XXX_i = 0x01CF, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, PathIsIgnored + /// + AAB_xxx_I = 0x020F, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, DiskArchived, PrevArchived, PathIsIgnored + /// + AAB_XXx_I = 0x02CF, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, LoadoutArchived, PathIsIgnored + /// + AAB_xxX_I = 0x030F, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, DiskArchived, PrevArchived, LoadoutArchived, PathIsIgnored + /// + AAB_XXX_I = 0x03CF, + /// + /// DiskExists, PrevExists + /// + ABx_xxx_i = 0x0003, + /// + /// DiskExists, PrevExists, DiskArchived + /// + ABx_Xxx_i = 0x0043, + /// + /// DiskExists, PrevExists, PrevArchived + /// + ABx_xXx_i = 0x0083, + /// + /// DiskExists, PrevExists, DiskArchived, PrevArchived + /// + ABx_XXx_i = 0x00C3, + /// + /// DiskExists, PrevExists, PathIsIgnored + /// + ABx_xxx_I = 0x0203, + /// + /// DiskExists, PrevExists, DiskArchived, PathIsIgnored + /// + ABx_Xxx_I = 0x0243, + /// + /// DiskExists, PrevExists, PrevArchived, PathIsIgnored + /// + ABx_xXx_I = 0x0283, + /// + /// DiskExists, PrevExists, DiskArchived, PrevArchived, PathIsIgnored + /// + ABx_XXx_I = 0x02C3, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsLoadout + /// + ABA_xxx_i = 0x0027, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsLoadout, DiskArchived, LoadoutArchived + /// + ABA_XxX_i = 0x0167, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsLoadout, PrevArchived + /// + ABA_xXx_i = 0x00A7, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsLoadout, DiskArchived, PrevArchived, LoadoutArchived + /// + ABA_XXX_i = 0x01E7, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsLoadout, PathIsIgnored + /// + ABA_xxx_I = 0x0227, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsLoadout, DiskArchived, LoadoutArchived, PathIsIgnored + /// + ABA_XxX_I = 0x0367, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsLoadout, PrevArchived, PathIsIgnored + /// + ABA_xXx_I = 0x02A7, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsLoadout, DiskArchived, PrevArchived, LoadoutArchived, PathIsIgnored + /// + ABA_XXX_I = 0x03E7, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevEqualsLoadout + /// + ABB_xxx_i = 0x0017, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevEqualsLoadout, DiskArchived + /// + ABB_Xxx_i = 0x0057, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevEqualsLoadout, PrevArchived, LoadoutArchived + /// + ABB_xXX_i = 0x0197, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevEqualsLoadout, DiskArchived, PrevArchived, LoadoutArchived + /// + ABB_XXX_i = 0x01D7, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevEqualsLoadout, PathIsIgnored + /// + ABB_xxx_I = 0x0217, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevEqualsLoadout, DiskArchived, PathIsIgnored + /// + ABB_Xxx_I = 0x0257, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevEqualsLoadout, PrevArchived, LoadoutArchived, PathIsIgnored + /// + ABB_xXX_I = 0x0397, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevEqualsLoadout, DiskArchived, PrevArchived, LoadoutArchived, PathIsIgnored + /// + ABB_XXX_I = 0x03D7, + /// + /// DiskExists, PrevExists, LoadoutExists + /// + ABC_xxx_i = 0x0007, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskArchived + /// + ABC_Xxx_i = 0x0047, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevArchived + /// + ABC_xXx_i = 0x0087, + /// + /// DiskExists, PrevExists, LoadoutExists, LoadoutArchived + /// + ABC_xxX_i = 0x0107, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskArchived, PrevArchived + /// + ABC_XXx_i = 0x00C7, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskArchived, LoadoutArchived + /// + ABC_XxX_i = 0x0147, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevArchived, LoadoutArchived + /// + ABC_xXX_i = 0x0187, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskArchived, PrevArchived, LoadoutArchived + /// + ABC_XXX_i = 0x01C7, + /// + /// DiskExists, PrevExists, LoadoutExists, PathIsIgnored + /// + ABC_xxx_I = 0x0207, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskArchived, PathIsIgnored + /// + ABC_Xxx_I = 0x0247, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevArchived, PathIsIgnored + /// + ABC_xXx_I = 0x0287, + /// + /// DiskExists, PrevExists, LoadoutExists, LoadoutArchived, PathIsIgnored + /// + ABC_xxX_I = 0x0307, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskArchived, PrevArchived, PathIsIgnored + /// + ABC_XXx_I = 0x02C7, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskArchived, LoadoutArchived, PathIsIgnored + /// + ABC_XxX_I = 0x0347, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevArchived, LoadoutArchived, PathIsIgnored + /// + ABC_xXX_I = 0x0387, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskArchived, PrevArchived, LoadoutArchived, PathIsIgnored + /// + ABC_XXX_I = 0x03C7, +} diff --git a/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/SyncActionGroupings.cs b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/SyncActionGroupings.cs new file mode 100644 index 0000000000..793602eda9 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/SyncActionGroupings.cs @@ -0,0 +1,37 @@ +using System.Collections.Concurrent; +using NexusMods.Abstractions.Loadouts.Synchronizers.Rules; + +namespace NexusMods.Abstractions.Loadouts.Synchronizers; + +/// +/// A grouping of actions that can be performed on a file. The SyncTree has a `Actions` +/// field and that field is used to put the items in the tree into these specific groups. +/// +public class SyncActionGroupings +{ + private ConcurrentDictionary> _groupings = new(); + + /// + /// Gets the group of nodes that have the specified action. + /// + /// + public IReadOnlyCollection this[Actions action] => _groupings.GetOrAdd(action, static _ => []); + + /// + /// Adds a node to the groupings based on the actions it has. + /// + /// + public void Add(SyncTreeNode node) + { + foreach (var flag in Enum.GetValues()) + { + if (node.Actions.HasFlag(flag)) + AddOne(flag, node); + } + } + + private void AddOne(Actions flag, SyncTreeNode node) + { + _groupings.GetOrAdd(flag, static _ => []).Add(node); + } +} diff --git a/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/SyncTree.cs b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/SyncTree.cs new file mode 100644 index 0000000000..04163d0204 --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/SyncTree.cs @@ -0,0 +1,11 @@ +using NexusMods.Abstractions.GameLocators; +using NexusMods.Abstractions.GameLocators.Trees; + +namespace NexusMods.Abstractions.Loadouts.Synchronizers; + +public class SyncTree : AGamePathNodeTree +{ + public SyncTree(IEnumerable> items) : base(items) + { + } +} diff --git a/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/SyncTreeNode.cs b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/SyncTreeNode.cs new file mode 100644 index 0000000000..92da53a51a --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Loadouts.Synchronizers/SyncTreeNode.cs @@ -0,0 +1,18 @@ +using DynamicData.Kernel; +using NexusMods.Abstractions.DiskState; +using NexusMods.Abstractions.GameLocators; +using NexusMods.Abstractions.Loadouts.Files; +using NexusMods.Abstractions.Loadouts.Synchronizers.Rules; + +namespace NexusMods.Abstractions.Loadouts.Synchronizers; + +public class SyncTreeNode +{ + public GamePath Path { get; init; } + public Optional Disk { get; set; } + public Optional Previous { get; set; } + public Optional LoadoutFile { get; set; } + public Signature Signature { get; set; } + public Actions Actions { get; set; } + +} diff --git a/src/Abstractions/NexusMods.Abstractions.Loadouts/IApplyService.cs b/src/Abstractions/NexusMods.Abstractions.Loadouts/IApplyService.cs index 223de8215d..7c5d04db9d 100644 --- a/src/Abstractions/NexusMods.Abstractions.Loadouts/IApplyService.cs +++ b/src/Abstractions/NexusMods.Abstractions.Loadouts/IApplyService.cs @@ -11,25 +11,15 @@ namespace NexusMods.Abstractions.Loadouts; public interface IApplyService { /// - /// Apply a loadout to its game installation. - /// This will also ingest outside changes and merge unapplied changes on top and apply them. + /// Synchronize the loadout with the game folder, any changes in the game folder will be added to the loadout, and any + /// new changes in the loadout will be applied to the game folder. /// - public Task Apply(Loadout.ReadOnly loadout); + public Task Synchronize(Loadout.ReadOnly loadout); /// /// Get the diff tree of the unapplied changes of a loadout. /// - public ValueTask GetApplyDiffTree(Loadout.ReadOnly loadout); - - - /// - /// Ingest any detected outside changes into the last applied loadout. - /// This will also rebase any unapplied changes on top of the last applied state, but will not apply them to disk. - /// The loadoutId will point to the new merged loadout - /// - /// - /// The merged loadout - public Task Ingest(GameInstallation gameInstallation); + public FileDiffTree GetApplyDiffTree(Loadout.ReadOnly loadout); /// /// Returns the last applied loadout for a given game installation. diff --git a/src/Games/NexusMods.Games.StardewValley/Emitters/DependencyDiagnosticEmitter.cs b/src/Games/NexusMods.Games.StardewValley/Emitters/DependencyDiagnosticEmitter.cs index 68a2846b7f..03b516f0c4 100644 --- a/src/Games/NexusMods.Games.StardewValley/Emitters/DependencyDiagnosticEmitter.cs +++ b/src/Games/NexusMods.Games.StardewValley/Emitters/DependencyDiagnosticEmitter.cs @@ -1,16 +1,13 @@ using System.Collections.Immutable; using System.Runtime.CompilerServices; using DynamicData.Kernel; -using Metsys.Bson; using Microsoft.Extensions.Logging; using NexusMods.Abstractions.Diagnostics; using NexusMods.Abstractions.Diagnostics.Emitters; using NexusMods.Abstractions.Diagnostics.References; -using NexusMods.Abstractions.Diagnostics.Values; using NexusMods.Abstractions.IO; using NexusMods.Abstractions.Loadouts; using NexusMods.Abstractions.Loadouts.Extensions; -using NexusMods.Abstractions.Loadouts.Ids; using NexusMods.Abstractions.Loadouts.Mods; using NexusMods.Games.StardewValley.Models; using NexusMods.Games.StardewValley.WebAPI; diff --git a/src/Games/NexusMods.Games.StardewValley/Emitters/MissingSMAPIEmitter.cs b/src/Games/NexusMods.Games.StardewValley/Emitters/MissingSMAPIEmitter.cs index c307d48d49..e3434aad52 100644 --- a/src/Games/NexusMods.Games.StardewValley/Emitters/MissingSMAPIEmitter.cs +++ b/src/Games/NexusMods.Games.StardewValley/Emitters/MissingSMAPIEmitter.cs @@ -1,7 +1,6 @@ using System.Runtime.CompilerServices; using NexusMods.Abstractions.Diagnostics; using NexusMods.Abstractions.Diagnostics.Emitters; -using NexusMods.Abstractions.Diagnostics.Values; using NexusMods.Abstractions.Loadouts; using NexusMods.Abstractions.Loadouts.Extensions; using NexusMods.Games.StardewValley.Models; diff --git a/src/Games/NexusMods.Games.StardewValley/Emitters/SMAPIModDatabaseCompatibilityDiagnosticEmitter.cs b/src/Games/NexusMods.Games.StardewValley/Emitters/SMAPIModDatabaseCompatibilityDiagnosticEmitter.cs index 1e64972f9c..a44a2f0386 100644 --- a/src/Games/NexusMods.Games.StardewValley/Emitters/SMAPIModDatabaseCompatibilityDiagnosticEmitter.cs +++ b/src/Games/NexusMods.Games.StardewValley/Emitters/SMAPIModDatabaseCompatibilityDiagnosticEmitter.cs @@ -1,5 +1,4 @@ using System.Runtime.CompilerServices; -using DynamicData.Kernel; using Microsoft.Extensions.Logging; using NexusMods.Abstractions.Diagnostics; using NexusMods.Abstractions.Diagnostics.Emitters; diff --git a/src/Games/NexusMods.Games.StardewValley/Emitters/VersionDiagnosticEmitter.cs b/src/Games/NexusMods.Games.StardewValley/Emitters/VersionDiagnosticEmitter.cs index 82a9d19cfd..ec0dd18d09 100644 --- a/src/Games/NexusMods.Games.StardewValley/Emitters/VersionDiagnosticEmitter.cs +++ b/src/Games/NexusMods.Games.StardewValley/Emitters/VersionDiagnosticEmitter.cs @@ -1,5 +1,4 @@ using System.Runtime.CompilerServices; -using DynamicData.Kernel; using JetBrains.Annotations; using Microsoft.Extensions.Logging; using NexusMods.Abstractions.Diagnostics; diff --git a/src/Games/NexusMods.Games.StardewValley/Interop.cs b/src/Games/NexusMods.Games.StardewValley/Interop.cs index 696499f2a0..ec070aba8f 100644 --- a/src/Games/NexusMods.Games.StardewValley/Interop.cs +++ b/src/Games/NexusMods.Games.StardewValley/Interop.cs @@ -1,6 +1,5 @@ using System.Text; using NexusMods.Abstractions.IO; -using NexusMods.Abstractions.Loadouts.Extensions; using NexusMods.Abstractions.Loadouts.Files; using NexusMods.Abstractions.Loadouts.Mods; using NexusMods.Games.StardewValley.Models; diff --git a/src/Games/NexusMods.Games.StardewValley/StardewValley.cs b/src/Games/NexusMods.Games.StardewValley/StardewValley.cs index a0f3b7c5b7..d2608985d3 100644 --- a/src/Games/NexusMods.Games.StardewValley/StardewValley.cs +++ b/src/Games/NexusMods.Games.StardewValley/StardewValley.cs @@ -101,7 +101,7 @@ protected override IReadOnlyDictionary GetLocations(IF public override List GetInstallDestinations(IReadOnlyDictionary locations) => ModInstallDestinationHelpers.GetCommonLocations(locations); - protected override IStandardizedLoadoutSynchronizer MakeSynchronizer(IServiceProvider provider) + protected override ILoadoutSynchronizer MakeSynchronizer(IServiceProvider provider) { return new StardewValleyLoadoutSynchronizer(provider); } diff --git a/src/Games/NexusMods.Games.StardewValley/StardewValleyLoadoutSynchronizer.cs b/src/Games/NexusMods.Games.StardewValley/StardewValleyLoadoutSynchronizer.cs index 2ab972e239..da14a7bc90 100644 --- a/src/Games/NexusMods.Games.StardewValley/StardewValleyLoadoutSynchronizer.cs +++ b/src/Games/NexusMods.Games.StardewValley/StardewValleyLoadoutSynchronizer.cs @@ -1,11 +1,10 @@ using NexusMods.Abstractions.GameLocators; using NexusMods.Abstractions.Loadouts; -using NexusMods.Abstractions.Loadouts.Ids; +using NexusMods.Abstractions.Loadouts.Files; using NexusMods.Abstractions.Loadouts.Mods; using NexusMods.Abstractions.Loadouts.Synchronizers; using NexusMods.Extensions.BCL; using NexusMods.MnemonicDB.Abstractions; -using NexusMods.MnemonicDB.Abstractions.Models; using NexusMods.Paths; using File = NexusMods.Abstractions.Loadouts.Files.File; @@ -16,28 +15,18 @@ public class StardewValleyLoadoutSynchronizer : ALoadoutSynchronizer public StardewValleyLoadoutSynchronizer(IServiceProvider provider) : base(provider) { } - protected override async Task AddChangedFilesToLoadout(Loadout.ReadOnly loadout, TempEntity[] newFiles) + protected override async Task MoveNewFilesToMods(Loadout.ReadOnly loadout, StoredFile.ReadOnly[] newFiles) { using var tx = Connection.BeginTransaction(); - var overridesMod = GetOrCreateOverridesMod(loadout, tx); var modifiedMods = new HashSet(); var smapiModDirectoryNameToModel = new Dictionary(); foreach (var newFile in newFiles) { - newFile.Add(File.Loadout, loadout.Id); - - if (!newFile.Contains(File.To)) - { - AddToOverride(newFile); - continue; - } - - var gamePath = newFile.GetFirst(File.To); + var gamePath = newFile.AsFile().To; if (!IsModFile(gamePath, out var modDirectoryName)) { - AddToOverride(newFile); continue; } @@ -45,40 +34,30 @@ public StardewValleyLoadoutSynchronizer(IServiceProvider provider) : base(provid { if (!TryGetSMAPIMod(modDirectoryName, loadout, loadout.Db, out smapiMod)) { - AddToOverride(newFile); continue; } smapiModDirectoryNameToModel[modDirectoryName] = smapiMod; } - newFile.Add(File.Mod, smapiMod.Id); - newFile.AddTo(tx); + tx.Add(newFile.Id, File.Mod, smapiMod.Id); modifiedMods.Add(smapiMod.ModId); } + // Revise all modified mods foreach (var modId in modifiedMods) { - // If we created the mod in this transaction (e.g. GetOrCreateOverride created the Override mod), - // Db property will be null, and we can't call `.Revise` on it. - // We need to manually revise the loadout in that case - if (modId.Value.Partition == PartitionId.Temp) - continue; - var mod = Mod.Load(Connection.Db, modId); mod.Revise(tx); } - loadout.Revise(tx); + // Only commit if we have changes + if (modifiedMods.Count <= 0) + return loadout; + + var result = await tx.Commit(); - return loadout.Rebase(result.Db); - - void AddToOverride(TempEntity newFile) - { - newFile.Add(File.Mod, overridesMod); - newFile.AddTo(tx); - modifiedMods.Add(overridesMod); - } + return loadout.Rebase(); } private static bool TryGetSMAPIMod(RelativePath modDirectoryName, Loadout.ReadOnly loadout, IDb db, out Mod.ReadOnly mod) diff --git a/src/NexusMods.App.UI/Controls/Trees/DiffTreeViewModel.cs b/src/NexusMods.App.UI/Controls/Trees/DiffTreeViewModel.cs index 5961389e4e..2ed6deb6cb 100644 --- a/src/NexusMods.App.UI/Controls/Trees/DiffTreeViewModel.cs +++ b/src/NexusMods.App.UI/Controls/Trees/DiffTreeViewModel.cs @@ -62,7 +62,7 @@ public async Task Refresh() throw new KeyNotFoundException($"Loadout with ID {_loadoutId} not found."); } - var diffTree = await _applyService.GetApplyDiffTree(loadout); + var diffTree = _applyService.GetApplyDiffTree(loadout); Dictionary fileViewModelNodes = []; diff --git a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs index 70cbf0177d..243a9013f2 100644 --- a/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs +++ b/src/NexusMods.App.UI/LeftMenu/Items/ApplyControlViewModel.cs @@ -145,7 +145,7 @@ private async Task Apply() await Task.Run(async () => { var loadout = Abstractions.Loadouts.Loadout.Load(_conn.Db, _loadoutId); - await _applyService.Apply(loadout); + await _applyService.Synchronize(loadout); }); } @@ -158,7 +158,7 @@ private async Task FirstLoadIngest() if (LastAppliedWithTxId.Id.Equals(_loadoutId)) { var loadout = Abstractions.Loadouts.Loadout.Load(_conn.Db, _loadoutId); - await _applyService.Ingest(loadout.InstallationInstance); + await _applyService.Synchronize(loadout); } } } diff --git a/src/NexusMods.DataModel/ApplyService.cs b/src/NexusMods.DataModel/ApplyService.cs index 3417617e67..650d0dc71d 100644 --- a/src/NexusMods.DataModel/ApplyService.cs +++ b/src/NexusMods.DataModel/ApplyService.cs @@ -1,16 +1,11 @@ -using System.Diagnostics.CodeAnalysis; -using System.Reactive.Linq; +using System.Reactive.Linq; using Microsoft.Extensions.Logging; using NexusMods.Abstractions.DiskState; using NexusMods.Abstractions.GameLocators; using NexusMods.Abstractions.Games; -using NexusMods.Abstractions.Games.Loadouts; using NexusMods.Abstractions.Loadouts; using NexusMods.Abstractions.Loadouts.Ids; using NexusMods.Abstractions.Loadouts.Synchronizers; -using NexusMods.Abstractions.MnemonicDB.Attributes; -using NexusMods.Abstractions.MnemonicDB.Attributes.Extensions; -using NexusMods.DataModel.Loadouts.Extensions; using NexusMods.MnemonicDB.Abstractions; namespace NexusMods.DataModel; @@ -31,49 +26,16 @@ public ApplyService(IDiskStateRegistry diskStateRegistry, IConnection conn, ILog _conn = conn; _diskStateRegistry = diskStateRegistry; } - + /// - public async Task Apply(Loadout.ReadOnly loadout) + public async Task Synchronize(Loadout.ReadOnly loadout) { - // TODO: Check if this or any other loadout is being applied to this game installation - // Queue the loadout to be applied if that is the case. - - _logger.LogInformation( - "Applying loadout {Name} to {GameName} {GameVersion}", - loadout.Name, - loadout.InstallationInstance.Game.Name, - loadout.InstallationInstance.Version - ); - - try - { - await loadout.Apply(); - } - catch (NeedsIngestException) - { - _logger.LogInformation("Ingesting loadout {Name} from {GameName} {GameVersion}", loadout.Name, - loadout.InstallationInstance.Game.Name, loadout.InstallationInstance.Version - ); - - if (TryGetLastAppliedLoadout(loadout.InstallationInstance, out var lastAppliedLoadout)) - { - _logger.LogInformation("Last applied loadout found: {LoadoutId} as of {TxId}", lastAppliedLoadout.Id, lastAppliedLoadout.MostRecentTxId()); - } - else - { - // There is apparently no last applied revision, so we'll just use the loadout we're trying to apply - lastAppliedLoadout = loadout; - } - - var loadoutWithIngest = await loadout.Ingest(); - - await loadoutWithIngest.Apply(); - } + await loadout.InstallationInstance.GetGame().Synchronizer.Synchronize(loadout); } /// - public ValueTask GetApplyDiffTree(Loadout.ReadOnly loadout) + public FileDiffTree GetApplyDiffTree(Loadout.ReadOnly loadout) { var prevDiskState = _diskStateRegistry.GetState(loadout.InstallationInstance)!; @@ -82,19 +44,6 @@ public ValueTask GetApplyDiffTree(Loadout.ReadOnly loadout) return syncrhonizer.LoadoutToDiskDiff(loadout, prevDiskState); } - /// - public async Task Ingest(GameInstallation gameInstallation) - { - if (!TryGetLastAppliedLoadout(gameInstallation, out var lastAppliedRevision)) - { - throw new InvalidOperationException("Game installation does not have a last applied loadout to ingest into"); - } - - var loadoutWithIngest = await gameInstallation.GetGame().Synchronizer.Ingest(lastAppliedRevision); - - return loadoutWithIngest; - } - /// public bool TryGetLastAppliedLoadout(GameInstallation gameInstallation, out Loadout.ReadOnly loadout) { diff --git a/src/NexusMods.DataModel/CommandLine/Verbs/LoadoutManagementVerbs.cs b/src/NexusMods.DataModel/CommandLine/Verbs/LoadoutManagementVerbs.cs index 9430953cc3..ca4439a6c2 100644 --- a/src/NexusMods.DataModel/CommandLine/Verbs/LoadoutManagementVerbs.cs +++ b/src/NexusMods.DataModel/CommandLine/Verbs/LoadoutManagementVerbs.cs @@ -4,20 +4,15 @@ using NexusMods.Abstractions.FileStore.ArchiveMetadata; using NexusMods.Abstractions.GameLocators; using NexusMods.Abstractions.Games; -using NexusMods.Abstractions.Games.Loadouts; using NexusMods.Abstractions.Games.Trees; using NexusMods.Abstractions.Installers; using NexusMods.Abstractions.Loadouts; using NexusMods.Abstractions.Loadouts.Files; -using NexusMods.Abstractions.Loadouts.Ids; using NexusMods.Abstractions.Loadouts.Synchronizers; -using NexusMods.DataModel.Loadouts; -using NexusMods.DataModel.Loadouts.Extensions; using NexusMods.MnemonicDB.Abstractions; using NexusMods.Paths; using NexusMods.ProxyConsole.Abstractions; using NexusMods.ProxyConsole.Abstractions.VerbDefinitions; -using IGeneratedFile = NexusMods.Abstractions.Loadouts.Synchronizers.IGeneratedFile; namespace NexusMods.DataModel.CommandLine.Verbs; @@ -33,12 +28,10 @@ public static class LoadoutManagementVerbs /// public static IServiceCollection AddLoadoutManagementVerbs(this IServiceCollection services) => services - .AddVerb(() => Apply) + .AddVerb(() => Synchronize) .AddVerb(() => ChangeTracking) - .AddVerb(() => FlattenLoadout) .AddVerb(() => Ingest) .AddVerb(() => InstallMod) - .AddVerb(() => ListHistory) .AddVerb(() => ListLoadouts) .AddVerb(() => ListModContents) .AddVerb(() => ListMods) @@ -46,12 +39,12 @@ public static IServiceCollection AddLoadoutManagementVerbs(this IServiceCollecti .AddVerb(() => RenameLoadout) .AddVerb(() => RemoveLoadout); - [Verb("apply", "Apply the given loadout to the game folder")] - private static async Task Apply([Injected] IRenderer renderer, + [Verb("synchronize", "Synchronize the loadout with the game folders, adding any changes in the game folder to the loadout and applying any new changes in the loadout to the game folder")] + private static async Task Synchronize([Injected] IRenderer renderer, [Option("l", "loadout", "Loadout to apply")] Loadout.ReadOnly loadout, [Injected] IApplyService applyService) { - await applyService.Apply(loadout); + await applyService.Synchronize(loadout); return 0; } @@ -88,30 +81,7 @@ private static async Task ChangeTracking([Injected] IRenderer renderer, return 0; */ } - - [Verb("flatten-loadout", "Flatten a loadout into the projected filesystem")] - private static async Task FlattenLoadout([Injected] IRenderer renderer, - [Option("l", "loadout", "Loadout to flatten")] - Loadout.ReadOnly loadout, - [Injected] CancellationToken token) - { - var rows = new List(); - var synchronizer = loadout.InstallationInstance.GetGame().Synchronizer as IStandardizedLoadoutSynchronizer; - if (synchronizer == null) - { - await renderer.Text($"{loadout.InstallationInstance.Game.Name} does not support flattening loadouts"); - return -1; - } - - var flattened = await synchronizer.LoadoutToFlattenedLoadout(loadout); - - foreach (var item in flattened.GetAllDescendentFiles().OrderBy(f => f.Item.GamePath)) - rows.Add([item.Item.Value!.Mod.Name, item.GamePath()]); - - await renderer.Table(["Mod", "To"], rows); - return 0; - } - + [Verb("install-mod", "Installs a mod into a loadout")] private static async Task InstallMod([Injected] IRenderer renderer, [Option("l", "loadout", "loadout to add the mod to")] Loadout.ReadOnly loadout, @@ -131,21 +101,6 @@ private static async Task InstallMod([Injected] IRenderer renderer, }); } - [Verb("list-history", "Lists the history of a loadout")] - private static async Task ListHistory([Injected] IRenderer renderer, - [Option("l", "loadout", "Loadout to load")] LoadoutId loadout, - [Injected] CancellationToken token) - { - throw new NotImplementedException(); - /* - var rows = loadout.History() - .Select(list => new object[] { list.LastModified, list.ChangeMessage, list.Mods.Count, list.DataStoreId }) - .ToList(); - - await renderer.Table(new[] { "Date", "Change Message", "Mod Count", "Id" }, rows); - return 0; - */ - } [Verb("list-loadouts", "Lists all the loadouts")] private static async Task ListLoadouts([Injected] IRenderer renderer, diff --git a/src/NexusMods.DataModel/LoadoutSynchronizer/Extensions/LoadoutExtensions.cs b/src/NexusMods.DataModel/LoadoutSynchronizer/Extensions/LoadoutExtensions.cs deleted file mode 100644 index f9c56d4bef..0000000000 --- a/src/NexusMods.DataModel/LoadoutSynchronizer/Extensions/LoadoutExtensions.cs +++ /dev/null @@ -1,36 +0,0 @@ -using NexusMods.Abstractions.GameLocators; -using NexusMods.Abstractions.Games; -using NexusMods.Abstractions.Games.DTO; -using NexusMods.Abstractions.Loadouts; -using NexusMods.Abstractions.Loadouts.Synchronizers; - -namespace NexusMods.DataModel.LoadoutSynchronizer.Extensions; - -/// -/// Various extension methods for Loadouts and the loadout registry -/// -public static class LoadoutExtensions -{ - - /// - /// Assuming the game has a IStandardizedLoadoutSynchronizer, this will flatten the loadout into a FlattenedLoadout. - /// - /// - /// - public static ValueTask ToFlattenedLoadout(this Loadout.ReadOnly loadout) - { - return ((IStandardizedLoadoutSynchronizer)loadout.InstallationInstance.GetGame().Synchronizer).LoadoutToFlattenedLoadout(loadout); - } - - /// - /// Assuming the game has a IStandardizedLoadoutSynchronizer, this will flatten the loadout into a FileTree. - /// - /// - /// - public static async ValueTask ToFileTree(this Loadout.ReadOnly loadout) - { - var fileTree = await loadout.ToFlattenedLoadout(); - return await ((IStandardizedLoadoutSynchronizer)loadout.InstallationInstance.GetGame().Synchronizer) - .FlattenedLoadoutToFileTree(fileTree, loadout); - } -} diff --git a/src/NexusMods.DataModel/Loadouts/Extensions/LoadoutExtensions.cs b/src/NexusMods.DataModel/Loadouts/Extensions/LoadoutExtensions.cs deleted file mode 100644 index 927a2f7bc1..0000000000 --- a/src/NexusMods.DataModel/Loadouts/Extensions/LoadoutExtensions.cs +++ /dev/null @@ -1,31 +0,0 @@ -using NexusMods.Abstractions.DiskState; -using NexusMods.Abstractions.Games; -using NexusMods.Abstractions.Loadouts; -using NexusMods.Abstractions.Loadouts.Synchronizers; - -namespace NexusMods.DataModel.Loadouts.Extensions; - -public static class LoadoutExtensions -{ - /// - /// Helper method to apply a loadout to the game folder, calls the method, - /// on the loadout's game's synchronizer. - /// - /// - /// - public static Task Apply(this Loadout.ReadOnly loadout) - { - return loadout.InstallationInstance.GetGame().Synchronizer.Apply(loadout); - } - - /// - /// Helper method to ingest changes from the game folder into the loadout, calls the method, - /// - /// - /// - public static Task Ingest(this Loadout.ReadOnly loadout) - { - return loadout.InstallationInstance.GetGame().Synchronizer.Ingest(loadout); - } - -} diff --git a/src/NexusMods.DataModel/NxFileStore.cs b/src/NexusMods.DataModel/NxFileStore.cs index edd8b49479..724b9b423a 100644 --- a/src/NexusMods.DataModel/NxFileStore.cs +++ b/src/NexusMods.DataModel/NxFileStore.cs @@ -125,7 +125,7 @@ private async Task UpdateIndexes(NxUnpacker unpacker, AbsolutePath finalPath) } /// - public async Task ExtractFiles((Hash Hash, AbsolutePath Dest)[] files, CancellationToken token = default) + public async Task ExtractFiles(IEnumerable<(Hash Hash, AbsolutePath Dest)> files, CancellationToken token = default) { // Group the files by archive. // In almost all cases, everything will go in one archive, except for cases diff --git a/src/NexusMods.DataModel/ToolManager.cs b/src/NexusMods.DataModel/ToolManager.cs index 57a0589643..90e375bf4d 100644 --- a/src/NexusMods.DataModel/ToolManager.cs +++ b/src/NexusMods.DataModel/ToolManager.cs @@ -49,8 +49,8 @@ public IEnumerable GetTools(Loadout.ReadOnly loadout) _logger.LogInformation("Applying loadout {LoadoutId} on {GameName} {GameVersion}", loadout.Id, loadout.InstallationInstance.Game.Name, loadout.InstallationInstance.Version); - await _applyService.Apply(loadout); - var appliedLoadout = loadout.Rebase(_conn.Db); + await _applyService.Synchronize(loadout); + var appliedLoadout = loadout.Rebase(); _logger.LogInformation("Running tool {ToolName} for loadout {LoadoutId} on {GameName} {GameVersion}", tool.Name, appliedLoadout.Id, appliedLoadout.InstallationInstance.Game.Name, appliedLoadout.InstallationInstance.Version); @@ -58,6 +58,8 @@ public IEnumerable GetTools(Loadout.ReadOnly loadout) _logger.LogInformation("Ingesting loadout {LoadoutId} from {GameName} {GameVersion}", appliedLoadout.Id, appliedLoadout.InstallationInstance.Game.Name, appliedLoadout.InstallationInstance.Version); - return await _applyService.Ingest(appliedLoadout.InstallationInstance); + await _applyService.Synchronize(appliedLoadout); + + return appliedLoadout.Rebase(); } } diff --git a/tests/Games/NexusMods.Games.StardewValley.Tests/StardewValleySynchronizerTests.cs b/tests/Games/NexusMods.Games.StardewValley.Tests/StardewValleySynchronizerTests.cs new file mode 100644 index 0000000000..4b3f41042f --- /dev/null +++ b/tests/Games/NexusMods.Games.StardewValley.Tests/StardewValleySynchronizerTests.cs @@ -0,0 +1,74 @@ +using FluentAssertions; +using NexusMods.Abstractions.GameLocators; +using NexusMods.Abstractions.Loadouts; +using NexusMods.Abstractions.Loadouts.Files; +using NexusMods.Abstractions.Loadouts.Mods; +using NexusMods.Extensions.BCL; +using NexusMods.Games.TestFramework; +using NexusMods.Hashing.xxHash64; +using NexusMods.Paths; +using NexusMods.Paths.Extensions; +using File = NexusMods.Abstractions.Loadouts.Files.File; + +namespace NexusMods.Games.StardewValley.Tests; + +public class StardewValleySynchronizerTests(IServiceProvider serviceProvider) : AGameTest(serviceProvider) +{ + [Fact] + public async Task FilesInModFoldersAreMovedIntoMods() + { + var loadout = await CreateLoadout(); + loadout = await Synchronizer.Synchronize(loadout); + + using var tx = Connection.BeginTransaction(); + + var mod = new Mod.New(tx) + { + LoadoutId = loadout, + Name = "Test Mod", + Revision = 0, + Enabled = true, + Category = ModCategory.Mod, + Status = ModStatus.Installed, + }; + + var manifestData = "{}"; + var manifestHash = manifestData.XxHash64AsUtf8(); + + var manfiestFile = new StoredFile.New(tx) + { + File = new File.New(tx) + { + To = new GamePath(LocationId.Game, "Mods/test_mod_42/manifest.json".ToRelativePath()), + ModId = mod, + LoadoutId = loadout, + }, + Hash = manifestHash, + Size = Size.FromLong(manifestData.Length), + }; + + var result = await tx.Commit(); + + var newModId = result.Remap(mod).Id; + + loadout = loadout.Rebase(); + loadout = await Synchronizer.Synchronize(loadout); + + var newFilePath = new GamePath(LocationId.Game, "Mods/test_mod_42/foo.dat".ToRelativePath()); + + var absPath = loadout.InstallationInstance.LocationsRegister.GetResolvedPath(newFilePath); + + absPath.Parent.CreateDirectory(); + await absPath.WriteAllTextAsync("Hello, World!"); + + loadout = await Synchronizer.Synchronize(loadout); + + loadout.Files + .TryGetFirst(f => f.To == newFilePath, out var found) + .Should().BeTrue("The file was ingested from the game folder"); + + found.Mod.Name.Should().Be("Test Mod", "The file was ingested into the parent mod folder"); + found.Mod.Id.Should().Be(newModId, "The file was ingested into the parent mod folder"); + } + +} diff --git a/tests/Games/NexusMods.Games.TestFramework/AGameTest.cs b/tests/Games/NexusMods.Games.TestFramework/AGameTest.cs index f0a0c6b844..1c1672a83f 100644 --- a/tests/Games/NexusMods.Games.TestFramework/AGameTest.cs +++ b/tests/Games/NexusMods.Games.TestFramework/AGameTest.cs @@ -15,6 +15,7 @@ using NexusMods.Abstractions.Loadouts; using NexusMods.Abstractions.Loadouts.Ids; using NexusMods.Abstractions.Loadouts.Mods; +using NexusMods.Abstractions.Loadouts.Synchronizers; using NexusMods.Abstractions.NexusWebApi; using NexusMods.Hashing.xxHash64; using NexusMods.MnemonicDB.Abstractions; @@ -46,6 +47,9 @@ public abstract class AGameTest where TGame : AGame protected readonly NexusApiClient NexusNexusApiClient; protected readonly IHttpDownloader HttpDownloader; + + protected ILoadoutSynchronizer Synchronizer => GameInstallation.GetGame().Synchronizer; + private readonly ILogger> _logger; /// diff --git a/tests/NexusMods.CLI.Tests/VerbTests/ModManagementVerbs.cs b/tests/NexusMods.CLI.Tests/VerbTests/ModManagementVerbs.cs index 9fd3158276..b44493a8bf 100644 --- a/tests/NexusMods.CLI.Tests/VerbTests/ModManagementVerbs.cs +++ b/tests/NexusMods.CLI.Tests/VerbTests/ModManagementVerbs.cs @@ -31,10 +31,7 @@ public async Task CanCreateAndManageLists() log = await Run("list-mod-contents", "-l", listName, "-m", Data7ZipLZMA2.GetFileNameWithoutExtension()); log.LastTable.Rows.Length.Should().Be(3); - - log = await Run("flatten-loadout", "-l", listName); - await VerifyLog(log, "flatten-loadout"); - log = await Run("apply", "-l", listName); + log = await Run("synchronize", "-l", listName); } } diff --git a/tests/NexusMods.DataModel.Tests/ALoadoutSynchronizerTests.cs b/tests/NexusMods.DataModel.Tests/ALoadoutSynchronizerTests.cs index ce87413e70..68edb56722 100644 --- a/tests/NexusMods.DataModel.Tests/ALoadoutSynchronizerTests.cs +++ b/tests/NexusMods.DataModel.Tests/ALoadoutSynchronizerTests.cs @@ -1,19 +1,12 @@ using FluentAssertions; -using GameFinder.Common; -using NexusMods.Abstractions.DataModel.Entities.Sorting; using NexusMods.Abstractions.GameLocators; using NexusMods.Abstractions.Games.Trees; using NexusMods.Abstractions.Loadouts; using NexusMods.Abstractions.Loadouts.Files; -using NexusMods.Abstractions.Loadouts.Ids; using NexusMods.Abstractions.Loadouts.Mods; -using NexusMods.Abstractions.Loadouts.Synchronizers; -using NexusMods.Abstractions.MnemonicDB.Attributes; -using NexusMods.Abstractions.MnemonicDB.Attributes.Extensions; using NexusMods.DataModel.Tests.Harness; using NexusMods.Extensions.Hashing; using NexusMods.Hashing.xxHash64; -using File = NexusMods.Abstractions.Loadouts.Files.File; namespace NexusMods.DataModel.Tests; @@ -88,10 +81,11 @@ public override async Task InitializeAsync() [Fact] public async Task ApplyingTwiceDoesNothing() { - // If apply is buggy, it will result in a "needs ingest" error when we try to re-apply. Because Apply - // will have not properly updated the disk state, and it will error because the disk state is not in sync - await Synchronizer.Apply(BaseLoadout); - await Synchronizer.Apply(BaseLoadout); + // This test is mostly a vestige of the apply/ingest system, but it's still a good sanity check to make + // sure that the system is idempotent. + + await Synchronizer.Synchronize(BaseLoadout); + await Synchronizer.Synchronize(BaseLoadout); // This should not throw as the disk state should be in sync @@ -100,14 +94,14 @@ public async Task ApplyingTwiceDoesNothing() await tx.Commit(); Refresh(ref BaseLoadout); - await Synchronizer.Apply(BaseLoadout); + await Synchronizer.Synchronize(BaseLoadout); } [Fact] public async Task ApplyingDeletesCleansUpEmptyDirectories() { - await Synchronizer.Apply(BaseLoadout); + await Synchronizer.Synchronize(BaseLoadout); var file1 = new GamePath(LocationId.Game, "deleteMeMod/deleteMeDir1/deleteMeFile.txt"); var path1 = Install.LocationsRegister.GetResolvedPath(file1); @@ -121,7 +115,7 @@ public async Task ApplyingDeletesCleansUpEmptyDirectories() (file2.Path, "deleteMeContents")); Refresh(ref BaseLoadout); - await Synchronizer.Apply(BaseLoadout); + await Synchronizer.Synchronize(BaseLoadout); path1.FileExists.Should().BeTrue("the file should exist"); @@ -133,7 +127,7 @@ public async Task ApplyingDeletesCleansUpEmptyDirectories() } Refresh(ref BaseLoadout); - await Synchronizer.Apply(BaseLoadout); + await Synchronizer.Synchronize(BaseLoadout); path1.FileExists.Should().BeFalse("the file should not exist"); path1.Parent.DirectoryExists().Should().BeFalse("the directory should not exist"); @@ -145,49 +139,14 @@ public async Task ApplyingDeletesCleansUpEmptyDirectories() var textureAbsPath = Install.LocationsRegister.GetResolvedPath(_texturePath.Parent); textureAbsPath.DirectoryExists().Should().BeTrue("the texture folder should still exist"); } - - [Fact] - public async Task CanFlattenLoadout() - { - var flattened = await Synchronizer.LoadoutToFlattenedLoadout(BaseLoadout); - var rows = flattened.GetAllDescendentFiles() - .Select(f => new - { - Path = f.GamePath().ToString(), - Mod = f.Item.Value.Mod.Name.ToString(), - } - ).OrderBy(f => f.Path); - - await Verify(rows); - } - [Fact] - public async Task CanCreateFileTree() + public async Task CanSynchronizeLoadout() { - var flattened = await Synchronizer.LoadoutToFlattenedLoadout(BaseLoadout); - var fileTree = await Synchronizer.FlattenedLoadoutToFileTree(flattened, BaseLoadout); - - var rows = fileTree.GetAllDescendentFiles() - .Select(f => new - { - Path = f.GamePath().ToString(), - Mod = f.Item.Value.Mod.Name.ToString(), - } - ).OrderBy(f => f.Path); + await Synchronizer.Synchronize(BaseLoadout); + + var diskState = DiskStateRegistry.GetState(BaseLoadout.InstallationInstance)!; - await Verify(rows); - } - - - [Fact] - public async Task CanWriteDiskTreeToDisk() - { - var flattened = await Synchronizer.LoadoutToFlattenedLoadout(BaseLoadout); - var fileTree = await Synchronizer.FlattenedLoadoutToFileTree(flattened, BaseLoadout); - var prevState = DiskStateRegistry.GetState(BaseLoadout.InstallationInstance)!; - var diskState = await Synchronizer.FileTreeToDisk(fileTree, BaseLoadout, flattened, prevState, Install); - diskState.GetAllDescendentFiles() .Select(f => f.GamePath().ToString()) .Should() @@ -217,8 +176,6 @@ public async Task CanWriteDiskTreeToDisk() var path = Install.LocationsRegister.GetResolvedPath(file.GamePath()); path.FileExists.Should().BeTrue("the file should exist on disk"); path.FileInfo.Size.Should().Be(file.Item.Value.Size, "the file size should match"); - path.FileInfo.LastWriteTimeUtc.Should() - .Be(file.Item.Value.LastModified, "the file last modified time should match"); (await path.XxHash64Async()).Should().Be(file.Item.Value.Hash, "the file hash should match"); } @@ -230,6 +187,7 @@ public async Task CanWriteDiskTreeToDisk() var executeFlags = UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; scriptPath.GetUnixFileMode().Should().HaveFlag(executeFlags); binaryPath.GetUnixFileMode().Should().HaveFlag(executeFlags); + } @@ -246,7 +204,7 @@ await AddMod("ReplacingConfigMod", ); Refresh(ref BaseLoadout); - var diffTree = await Synchronizer.LoadoutToDiskDiff(BaseLoadout, prevDiskState ); + var diffTree = Synchronizer.LoadoutToDiskDiff(BaseLoadout, prevDiskState ); var res = diffTree.GetAllDescendentFiles() .Select(node => VerifiableFile.From(node.Item.Value)) .OrderByDescending(mod => mod.GamePath) @@ -260,7 +218,7 @@ await AddMod("ReplacingConfigMod", public async Task CanIngestDiskState() { // Apply the old state - await Synchronizer.Apply(BaseLoadout); + await Synchronizer.Synchronize(BaseLoadout); // Setup some paths var modifiedFile = new GamePath(LocationId.Game, "meshes/b.nif"); @@ -269,12 +227,15 @@ public async Task CanIngestDiskState() // Modify the files on disk Install.LocationsRegister.GetResolvedPath(deletedFile).Delete(); - await Install.LocationsRegister.GetResolvedPath(modifiedFile).WriteAllBytesAsync(new byte[] { 0x01, 0x02, 0x03 }); - await Install.LocationsRegister.GetResolvedPath(newFile).WriteAllBytesAsync(new byte[] { 0x04, 0x05, 0x06 }); - - var diskState = await Synchronizer.GetDiskState(Install); + await Install.LocationsRegister.GetResolvedPath(modifiedFile).WriteAllBytesAsync([0x01, 0x02, 0x03]); + await Install.LocationsRegister.GetResolvedPath(newFile).WriteAllBytesAsync([0x04, 0x05, 0x06]); + + await Synchronizer.Synchronize(BaseLoadout); + + var diskState = DiskStateRegistry.GetState(Install)!; - diskState.GetAllDescendentFiles() + diskState + .GetAllDescendentFiles() .Select(f => f.GamePath().ToString()) .Should() .BeEquivalentTo(new[] @@ -297,7 +258,7 @@ public async Task CanIngestDiskState() "{Preferences}/preferences/settings.ini", // newFile: newSave.dat is created "{Saves}/saves/newSave.dat", - "{Saves}/saves/save1.dat" + "{Saves}/saves/save1.dat", }, "files have all been written to disk"); @@ -305,150 +266,20 @@ public async Task CanIngestDiskState() diskState[newFile].Item.Value.Hash.Should().Be(new byte[] { 0x04, 0x05, 0x06 }.XxHash64(), "the file should have been created"); } - - - [Fact] - public async Task CanIngestFileTree() - { - // Apply the old state - await Synchronizer.Apply(BaseLoadout); - - // Setup some paths - var modifiedFile = new GamePath(LocationId.Game, "meshes/b.nif"); - var newFile = new GamePath(LocationId.Saves, "saves/newSave.dat"); - var deletedFile = new GamePath(LocationId.Game, "perMod/9.dat"); - - // Modify the files on disk - Install.LocationsRegister.GetResolvedPath(deletedFile).Delete(); - await Install.LocationsRegister.GetResolvedPath(modifiedFile).WriteAllBytesAsync(new byte[] { 0x01, 0x02, 0x03 }); - await Install.LocationsRegister.GetResolvedPath(newFile).WriteAllBytesAsync(new byte[] { 0x04, 0x05, 0x06 }); - - var diskState = await Synchronizer.GetDiskState(Install); - - // Reconstruct the previous file tree - var prevFlattenedLoadout = await Synchronizer.LoadoutToFlattenedLoadout(BaseLoadout); - var prevFileTree = await Synchronizer.FlattenedLoadoutToFileTree(prevFlattenedLoadout, BaseLoadout); - var prevDiskState = DiskStateRegistry.GetState(BaseLoadout.InstallationInstance); - - var fileTree = await Synchronizer.DiskToFileTree(diskState, BaseLoadout, prevFileTree, prevDiskState); - - fileTree.GetAllDescendentFiles() - .Select(f => f.GamePath().ToString()) - .Should() - .BeEquivalentTo(new[] - { - // modifiedFile: b.nif is modified, so it should be included - "{Game}/meshes/b.nif", - "{Game}/perMod/0.dat", - "{Game}/perMod/1.dat", - "{Game}/perMod/2.dat", - "{Game}/perMod/3.dat", - "{Game}/perMod/4.dat", - "{Game}/perMod/5.dat", - "{Game}/perMod/6.dat", - "{Game}/perMod/7.dat", - "{Game}/perMod/8.dat", - "{Game}/bin/script.sh", - "{Game}/bin/binary", - // deletedFile: 9.dat is deleted - "{Game}/textures/a.dds", - "{Preferences}/preferences/settings.ini", - // newFile: newSave.dat is created - "{Saves}/saves/newSave.dat", - "{Saves}/saves/save1.dat" - }, - "files have all been written to disk"); - - fileTree[modifiedFile].Item.Value.TryGetAsStoredFile(out var stored); - stored.Hash.Should().Be(new byte[] { 0x01, 0x02, 0x03 }.XxHash64(), "the file should have been modified"); - - fileTree[newFile].Item.Value.TryGetAsStoredFile(out stored); - stored.Hash.Should().Be(new byte[] { 0x04, 0x05, 0x06 }.XxHash64(), "the file should have been created"); - - fileTree[deletedFile].Should().BeNull("the file should have been deleted"); - - } - - [Fact] - public async Task CanIngestFlattenedList() - { - // Apply the old state - await Synchronizer.Apply(BaseLoadout); - - // Setup some paths - var modifiedFile = new GamePath(LocationId.Game, "meshes/b.nif"); - var newFile = new GamePath(LocationId.Saves, "saves/newSave.dat"); - var deletedFile = new GamePath(LocationId.Game, "perMod/9.dat"); - - // Modify the files on disk - Install.LocationsRegister.GetResolvedPath(deletedFile).Delete(); - await Install.LocationsRegister.GetResolvedPath(modifiedFile).WriteAllBytesAsync(new byte[] { 0x01, 0x02, 0x03 }); - await Install.LocationsRegister.GetResolvedPath(newFile).WriteAllBytesAsync(new byte[] { 0x04, 0x05, 0x06 }); - - var diskState = await Synchronizer.GetDiskState(Install); - - // Reconstruct the previous file tree - var prevFlattenedLoadout = await Synchronizer.LoadoutToFlattenedLoadout(BaseLoadout); - var prevFileTree = await Synchronizer.FlattenedLoadoutToFileTree(prevFlattenedLoadout, BaseLoadout); - var prevDiskState = DiskStateRegistry.GetState(BaseLoadout.InstallationInstance)!; - - var fileTree = await Synchronizer.DiskToFileTree(diskState, BaseLoadout, prevFileTree, prevDiskState); - var flattenedLoadout = await Synchronizer.FileTreeToFlattenedLoadout(fileTree, BaseLoadout, prevFlattenedLoadout); - - flattenedLoadout.GetAllDescendentFiles() - .Select(f => f.GamePath().ToString()) - .Should() - .BeEquivalentTo(new[] - { - // modifiedFile: b.nif is modified, so it should be included - "{Game}/meshes/b.nif", - "{Game}/perMod/0.dat", - "{Game}/perMod/1.dat", - "{Game}/perMod/2.dat", - "{Game}/perMod/3.dat", - "{Game}/perMod/4.dat", - "{Game}/perMod/5.dat", - "{Game}/perMod/6.dat", - "{Game}/perMod/7.dat", - "{Game}/perMod/8.dat", - "{Game}/bin/script.sh", - "{Game}/bin/binary", - // deletedFile: 9.dat is deleted - "{Game}/textures/a.dds", - "{Preferences}/preferences/settings.ini", - // newFile: newSave.dat is created - "{Saves}/saves/newSave.dat", - "{Saves}/saves/save1.dat" - }, - "files have all been written to disk"); - - var flattenedModifiedPair = flattenedLoadout[modifiedFile].Item.Value; - flattenedModifiedPair.TryGetAsStoredFile(out var flattenedModifiedFile).Should().BeTrue(); - flattenedModifiedFile.Hash.Should().Be(new byte[] { 0x01, 0x02, 0x03 }.XxHash64(), "the file should have been modified"); - - var flattenedNewPair = flattenedLoadout[newFile].Item.Value; - flattenedNewPair.TryGetAsStoredFile(out var flattenedNewFile).Should().BeTrue(); - flattenedNewFile.Hash.Should().Be(new byte[] { 0x04, 0x05, 0x06 }.XxHash64(), "the file should have been created"); - var newMod = flattenedNewPair.Mod; - newMod.Category.Should().Be(ModCategory.Overrides, "the mod should be in the overrides category"); - newMod.Name.Should().Be("Overrides", "the mod is the overrides mod"); - - flattenedLoadout[deletedFile].Should().BeNull("the file should have been deleted"); - } [Fact] public async Task CanSwitchBetweenLoadouts() { var secondLoadout = await Game.Synchronizer.CreateLoadout(Install, "Second Loadout"); - await ApplyService.Apply(secondLoadout); + await ApplyService.Synchronize(secondLoadout); FilesVerify(secondLoadout).Should().BeTrue(); FilesVerify(BaseLoadout).Should().BeFalse(); - await ApplyService.Apply(BaseLoadout); + await ApplyService.Synchronize(BaseLoadout); FilesVerify(secondLoadout).Should().BeFalse(); FilesVerify(BaseLoadout).Should().BeTrue(); - await ApplyService.Apply(secondLoadout); + await ApplyService.Synchronize(secondLoadout); FilesVerify(secondLoadout).Should().BeTrue(); FilesVerify(BaseLoadout).Should().BeFalse(); @@ -470,7 +301,7 @@ public async Task ManageGame_SecondLoadout_UsesInitialDiskState() // Arrange // Get our initial game disk state var initialLoadout = BaseLoadout; - var initialDiskState = await Synchronizer.GetDiskState(initialLoadout.InstallationInstance); + var initialDiskState = await Synchronizer.GetDiskState(initialLoadout); // Check that the files added by the first mod don't already exist, for sanity. var textureAbsPath = initialLoadout.InstallationInstance.LocationsRegister.GetResolvedPath(_texturePath); @@ -484,7 +315,7 @@ await AddMod("TestMod", (_meshPath.Path, "mesh.nif")); // Apply the initial loadout - await Synchronizer.Apply(initialLoadout); + await Synchronizer.Synchronize(initialLoadout); // Assert that the new files were deployed to disk after the first apply textureAbsPath.FileExists.Should().BeTrue("The texture file should exist after applying the initial loadout"); @@ -496,20 +327,20 @@ await AddMod("TestMod", var secondLoadout = await Synchronizer.CreateLoadout(Install, "Second Loadout"); // Assert - var secondLoadoutDiskState = await Synchronizer.GetDiskState(secondLoadout.InstallationInstance); + var secondLoadoutDiskState = await Synchronizer.GetDiskState(secondLoadout); // Check that the second loadout's initial disk state matches the original initial disk state secondLoadoutDiskState.Should().BeEquivalentTo(initialDiskState); // Check that the second loadout only contains the original game files - var secondLoadoutFileTree = await Synchronizer.LoadoutToFlattenedLoadout(secondLoadout); - secondLoadoutFileTree.GetAllDescendentFiles() + secondLoadoutDiskState.GetAllDescendentFiles() .Select(f => f.GamePath().ToString()) .Should() .NotContain(_texturePath.ToString()) .And .NotContain(_meshPath.ToString()); + // Check that the files added by the first loadout are not present in the second loadout textureAbsPath.FileExists.Should().BeFalse("The texture file should not exist in the second loadout"); meshAbsPath.FileExists.Should().BeFalse("The mesh file should not exist in the second loadout"); @@ -527,7 +358,7 @@ await AddMod("TestMod", (_texturePath.Path, "texture.dds"), (_meshPath.Path, "mesh.nif")); - await Synchronizer.Apply(initialLoadout); + await Synchronizer.Synchronize(initialLoadout); var textureAbsPath = initialLoadout.InstallationInstance.LocationsRegister.GetResolvedPath(_texturePath); var meshAbsPath = initialLoadout.InstallationInstance.LocationsRegister.GetResolvedPath(_meshPath); diff --git a/tests/NexusMods.DataModel.Tests/ApplyServiceTests.cs b/tests/NexusMods.DataModel.Tests/ApplyServiceTests.cs index 3c6a662447..5269f6b8aa 100644 --- a/tests/NexusMods.DataModel.Tests/ApplyServiceTests.cs +++ b/tests/NexusMods.DataModel.Tests/ApplyServiceTests.cs @@ -26,7 +26,7 @@ public async Task CanApplyLoadout() gameFolder.Combine("rootFile.txt").FileExists.Should().BeFalse("loadout has not yet been applied"); // Act - await ApplyService.Apply(BaseLoadout); + await ApplyService.Synchronize(BaseLoadout); // Assert gameFolder.Combine("rootFile.txt").FileExists.Should().BeTrue("loadout has been applied"); @@ -48,7 +48,7 @@ public async Task CanApplyAndIngestLoadout() // Act var newFile = new GamePath(LocationId.Saves, "newfile.dat"); await Install.LocationsRegister.GetResolvedPath(newFile).WriteAllBytesAsync([0x01, 0x02, 0x03]); - await ApplyService.Apply(BaseLoadout); + await ApplyService.Synchronize(BaseLoadout); // Assert gameFolder.Combine("rootFile.txt").FileExists.Should().BeTrue("loadout has been applied"); @@ -74,10 +74,12 @@ public async Task CanIngestNewFiles() // Act var newFile = new GamePath(LocationId.Saves, "newDiskFile.dat"); await Install.LocationsRegister.GetResolvedPath(newFile).WriteAllBytesAsync([0x01, 0x02, 0x03]); - var loadout = await ApplyService.Ingest(BaseLoadout.InstallationInstance); + await ApplyService.Synchronize(BaseLoadout); + + Refresh(ref BaseLoadout); // Assert - loadout!.Mods.SelectMany(mod=> mod.Files) + BaseLoadout!.Mods.SelectMany(mod=> mod.Files) .Should().Contain(file => file.To.EndsWith( "newDiskFile.dat")); } @@ -92,13 +94,13 @@ public async Task CanIngestDeletedFiles() var deletedFile = Install.LocationsRegister.GetResolvedPath(new GamePath(LocationId.Game, "rootFile.txt")); // Apply the loadout to make sure there are no uncommitted revisions - await ApplyService.Apply(BaseLoadout); + await ApplyService.Synchronize(BaseLoadout); deletedFile.FileExists.Should().BeTrue("the file was applied"); // Act deletedFile.Delete(); deletedFile.FileExists.Should().BeFalse("the file was deleted"); - await ApplyService.Ingest(BaseLoadout.InstallationInstance); + await ApplyService.Synchronize(BaseLoadout); Refresh(ref BaseLoadout); // Assert @@ -109,15 +111,12 @@ public async Task CanIngestDeletedFiles() overrideFile.Should().NotBeNull(); overrideFile!.Contains(DeletedFile.Size).Should().BeTrue(); + var syncTree = await Synchronizer.BuildSyncTree(BaseLoadout); // Act - await ApplyService.Apply(BaseLoadout); + await ApplyService.Synchronize(BaseLoadout); - - // This only fails on Windows on github actions, I don't know why, disabled for now on windows - // until I can investigate it - halgari - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - deletedFile.FileExists.Should().BeFalse("file is still deleted after apply"); + deletedFile.FileExists.Should().BeFalse("file is still deleted after apply"); } } diff --git a/tests/NexusMods.DataModel.Tests/Harness/ADataModelTest.cs b/tests/NexusMods.DataModel.Tests/Harness/ADataModelTest.cs index a684bd2c8a..3463ff4295 100644 --- a/tests/NexusMods.DataModel.Tests/Harness/ADataModelTest.cs +++ b/tests/NexusMods.DataModel.Tests/Harness/ADataModelTest.cs @@ -44,7 +44,7 @@ public abstract class ADataModelTest : IDisposable, IAsyncLifetime protected readonly DiskStateRegistry DiskStateRegistry; protected readonly IToolManager ToolManager; protected readonly IGameRegistry GameRegistry; - protected IStandardizedLoadoutSynchronizer Synchronizer; + protected ILoadoutSynchronizer Synchronizer; protected IGame Game; protected GameInstallation Install; @@ -93,7 +93,7 @@ public virtual async Task InitializeAsync() await _host.StartAsync(Token); Install = await StubbedGame.Create(ServiceProvider); Game = (IGame)Install.Game; - Synchronizer = (IStandardizedLoadoutSynchronizer)Game.Synchronizer; + Synchronizer = Game.Synchronizer; BaseLoadout = await Synchronizer.CreateLoadout(Install, "TestLoadout_" + Guid.NewGuid()); } diff --git a/tests/NexusMods.DataModel.Tests/SynchronizerRuleTests.RulesAreAsExpected.verified.txt b/tests/NexusMods.DataModel.Tests/SynchronizerRuleTests.RulesAreAsExpected.verified.txt new file mode 100644 index 0000000000..ab27752ce7 --- /dev/null +++ b/tests/NexusMods.DataModel.Tests/SynchronizerRuleTests.RulesAreAsExpected.verified.txt @@ -0,0 +1,374 @@ +/// +/// Summary of all 92 possible signatures in a somewhat readable format +/// +public enum SignatureShorthand : ushort +{ + /// + /// LoadoutExists + /// + xxA_xxx_i = 0x0004, + /// + /// LoadoutExists, LoadoutArchived + /// + xxA_xxX_i = 0x0104, + /// + /// LoadoutExists, PathIsIgnored + /// + xxA_xxx_I = 0x0204, + /// + /// LoadoutExists, LoadoutArchived, PathIsIgnored + /// + xxA_xxX_I = 0x0304, + /// + /// PrevExists + /// + xAx_xxx_i = 0x0002, + /// + /// PrevExists, PrevArchived + /// + xAx_xXx_i = 0x0082, + /// + /// PrevExists, PathIsIgnored + /// + xAx_xxx_I = 0x0202, + /// + /// PrevExists, PrevArchived, PathIsIgnored + /// + xAx_xXx_I = 0x0282, + /// + /// PrevExists, LoadoutExists, PrevEqualsLoadout + /// + xAA_xxx_i = 0x0016, + /// + /// PrevExists, LoadoutExists, PrevEqualsLoadout, PrevArchived, LoadoutArchived + /// + xAA_xXX_i = 0x0196, + /// + /// PrevExists, LoadoutExists, PrevEqualsLoadout, PathIsIgnored + /// + xAA_xxx_I = 0x0216, + /// + /// PrevExists, LoadoutExists, PrevEqualsLoadout, PrevArchived, LoadoutArchived, PathIsIgnored + /// + xAA_xXX_I = 0x0396, + /// + /// PrevExists, LoadoutExists + /// + xAB_xxx_i = 0x0006, + /// + /// PrevExists, LoadoutExists, PrevArchived + /// + xAB_xXx_i = 0x0086, + /// + /// PrevExists, LoadoutExists, LoadoutArchived + /// + xAB_xxX_i = 0x0106, + /// + /// PrevExists, LoadoutExists, PrevArchived, LoadoutArchived + /// + xAB_xXX_i = 0x0186, + /// + /// PrevExists, LoadoutExists, PathIsIgnored + /// + xAB_xxx_I = 0x0206, + /// + /// PrevExists, LoadoutExists, PrevArchived, PathIsIgnored + /// + xAB_xXx_I = 0x0286, + /// + /// PrevExists, LoadoutExists, LoadoutArchived, PathIsIgnored + /// + xAB_xxX_I = 0x0306, + /// + /// PrevExists, LoadoutExists, PrevArchived, LoadoutArchived, PathIsIgnored + /// + xAB_xXX_I = 0x0386, + /// + /// DiskExists + /// + Axx_xxx_i = 0x0001, + /// + /// DiskExists, DiskArchived + /// + Axx_Xxx_i = 0x0041, + /// + /// DiskExists, PathIsIgnored + /// + Axx_xxx_I = 0x0201, + /// + /// DiskExists, DiskArchived, PathIsIgnored + /// + Axx_Xxx_I = 0x0241, + /// + /// DiskExists, LoadoutExists, DiskEqualsLoadout + /// + AxA_xxx_i = 0x0025, + /// + /// DiskExists, LoadoutExists, DiskEqualsLoadout, DiskArchived, LoadoutArchived + /// + AxA_XxX_i = 0x0165, + /// + /// DiskExists, LoadoutExists, DiskEqualsLoadout, PathIsIgnored + /// + AxA_xxx_I = 0x0225, + /// + /// DiskExists, LoadoutExists, DiskEqualsLoadout, DiskArchived, LoadoutArchived, PathIsIgnored + /// + AxA_XxX_I = 0x0365, + /// + /// DiskExists, LoadoutExists + /// + AxB_xxx_i = 0x0005, + /// + /// DiskExists, LoadoutExists, DiskArchived + /// + AxB_Xxx_i = 0x0045, + /// + /// DiskExists, LoadoutExists, LoadoutArchived + /// + AxB_xxX_i = 0x0105, + /// + /// DiskExists, LoadoutExists, DiskArchived, LoadoutArchived + /// + AxB_XxX_i = 0x0145, + /// + /// DiskExists, LoadoutExists, PathIsIgnored + /// + AxB_xxx_I = 0x0205, + /// + /// DiskExists, LoadoutExists, DiskArchived, PathIsIgnored + /// + AxB_Xxx_I = 0x0245, + /// + /// DiskExists, LoadoutExists, LoadoutArchived, PathIsIgnored + /// + AxB_xxX_I = 0x0305, + /// + /// DiskExists, LoadoutExists, DiskArchived, LoadoutArchived, PathIsIgnored + /// + AxB_XxX_I = 0x0345, + /// + /// DiskExists, PrevExists, DiskEqualsPrev + /// + AAx_xxx_i = 0x000B, + /// + /// DiskExists, PrevExists, DiskEqualsPrev, DiskArchived, PrevArchived + /// + AAx_XXx_i = 0x00CB, + /// + /// DiskExists, PrevExists, DiskEqualsPrev, PathIsIgnored + /// + AAx_xxx_I = 0x020B, + /// + /// DiskExists, PrevExists, DiskEqualsPrev, DiskArchived, PrevArchived, PathIsIgnored + /// + AAx_XXx_I = 0x02CB, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, PrevEqualsLoadout, DiskEqualsLoadout + /// + AAA_xxx_i = 0x003F, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, PrevEqualsLoadout, DiskEqualsLoadout, DiskArchived, PrevArchived, LoadoutArchived + /// + AAA_XXX_i = 0x01FF, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, PrevEqualsLoadout, DiskEqualsLoadout, PathIsIgnored + /// + AAA_xxx_I = 0x023F, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, PrevEqualsLoadout, DiskEqualsLoadout, DiskArchived, PrevArchived, LoadoutArchived, PathIsIgnored + /// + AAA_XXX_I = 0x03FF, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev + /// + AAB_xxx_i = 0x000F, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, DiskArchived, PrevArchived + /// + AAB_XXx_i = 0x00CF, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, LoadoutArchived + /// + AAB_xxX_i = 0x010F, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, DiskArchived, PrevArchived, LoadoutArchived + /// + AAB_XXX_i = 0x01CF, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, PathIsIgnored + /// + AAB_xxx_I = 0x020F, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, DiskArchived, PrevArchived, PathIsIgnored + /// + AAB_XXx_I = 0x02CF, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, LoadoutArchived, PathIsIgnored + /// + AAB_xxX_I = 0x030F, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsPrev, DiskArchived, PrevArchived, LoadoutArchived, PathIsIgnored + /// + AAB_XXX_I = 0x03CF, + /// + /// DiskExists, PrevExists + /// + ABx_xxx_i = 0x0003, + /// + /// DiskExists, PrevExists, DiskArchived + /// + ABx_Xxx_i = 0x0043, + /// + /// DiskExists, PrevExists, PrevArchived + /// + ABx_xXx_i = 0x0083, + /// + /// DiskExists, PrevExists, DiskArchived, PrevArchived + /// + ABx_XXx_i = 0x00C3, + /// + /// DiskExists, PrevExists, PathIsIgnored + /// + ABx_xxx_I = 0x0203, + /// + /// DiskExists, PrevExists, DiskArchived, PathIsIgnored + /// + ABx_Xxx_I = 0x0243, + /// + /// DiskExists, PrevExists, PrevArchived, PathIsIgnored + /// + ABx_xXx_I = 0x0283, + /// + /// DiskExists, PrevExists, DiskArchived, PrevArchived, PathIsIgnored + /// + ABx_XXx_I = 0x02C3, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsLoadout + /// + ABA_xxx_i = 0x0027, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsLoadout, DiskArchived, LoadoutArchived + /// + ABA_XxX_i = 0x0167, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsLoadout, PrevArchived + /// + ABA_xXx_i = 0x00A7, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsLoadout, DiskArchived, PrevArchived, LoadoutArchived + /// + ABA_XXX_i = 0x01E7, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsLoadout, PathIsIgnored + /// + ABA_xxx_I = 0x0227, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsLoadout, DiskArchived, LoadoutArchived, PathIsIgnored + /// + ABA_XxX_I = 0x0367, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsLoadout, PrevArchived, PathIsIgnored + /// + ABA_xXx_I = 0x02A7, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskEqualsLoadout, DiskArchived, PrevArchived, LoadoutArchived, PathIsIgnored + /// + ABA_XXX_I = 0x03E7, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevEqualsLoadout + /// + ABB_xxx_i = 0x0017, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevEqualsLoadout, DiskArchived + /// + ABB_Xxx_i = 0x0057, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevEqualsLoadout, PrevArchived, LoadoutArchived + /// + ABB_xXX_i = 0x0197, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevEqualsLoadout, DiskArchived, PrevArchived, LoadoutArchived + /// + ABB_XXX_i = 0x01D7, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevEqualsLoadout, PathIsIgnored + /// + ABB_xxx_I = 0x0217, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevEqualsLoadout, DiskArchived, PathIsIgnored + /// + ABB_Xxx_I = 0x0257, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevEqualsLoadout, PrevArchived, LoadoutArchived, PathIsIgnored + /// + ABB_xXX_I = 0x0397, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevEqualsLoadout, DiskArchived, PrevArchived, LoadoutArchived, PathIsIgnored + /// + ABB_XXX_I = 0x03D7, + /// + /// DiskExists, PrevExists, LoadoutExists + /// + ABC_xxx_i = 0x0007, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskArchived + /// + ABC_Xxx_i = 0x0047, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevArchived + /// + ABC_xXx_i = 0x0087, + /// + /// DiskExists, PrevExists, LoadoutExists, LoadoutArchived + /// + ABC_xxX_i = 0x0107, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskArchived, PrevArchived + /// + ABC_XXx_i = 0x00C7, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskArchived, LoadoutArchived + /// + ABC_XxX_i = 0x0147, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevArchived, LoadoutArchived + /// + ABC_xXX_i = 0x0187, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskArchived, PrevArchived, LoadoutArchived + /// + ABC_XXX_i = 0x01C7, + /// + /// DiskExists, PrevExists, LoadoutExists, PathIsIgnored + /// + ABC_xxx_I = 0x0207, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskArchived, PathIsIgnored + /// + ABC_Xxx_I = 0x0247, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevArchived, PathIsIgnored + /// + ABC_xXx_I = 0x0287, + /// + /// DiskExists, PrevExists, LoadoutExists, LoadoutArchived, PathIsIgnored + /// + ABC_xxX_I = 0x0307, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskArchived, PrevArchived, PathIsIgnored + /// + ABC_XXx_I = 0x02C7, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskArchived, LoadoutArchived, PathIsIgnored + /// + ABC_XxX_I = 0x0347, + /// + /// DiskExists, PrevExists, LoadoutExists, PrevArchived, LoadoutArchived, PathIsIgnored + /// + ABC_xXX_I = 0x0387, + /// + /// DiskExists, PrevExists, LoadoutExists, DiskArchived, PrevArchived, LoadoutArchived, PathIsIgnored + /// + ABC_XXX_I = 0x03C7, +} diff --git a/tests/NexusMods.DataModel.Tests/SynchronizerRuleTests.cs b/tests/NexusMods.DataModel.Tests/SynchronizerRuleTests.cs new file mode 100644 index 0000000000..856c361a43 --- /dev/null +++ b/tests/NexusMods.DataModel.Tests/SynchronizerRuleTests.cs @@ -0,0 +1,129 @@ +using System.Security.Cryptography; +using System.Text; +using DynamicData.Kernel; +using FluentAssertions; +using NexusMods.Abstractions.Loadouts.Synchronizers.Rules; +using NexusMods.Hashing.xxHash64; +using Xunit.DependencyInjection; +using static NexusMods.Abstractions.Loadouts.Synchronizers.Rules.Actions; +using static NexusMods.Abstractions.Loadouts.Synchronizers.Rules.Signature; + +namespace NexusMods.DataModel.Tests; + +public class SynchronizerRuleTests +{ + + [Theory] + [MethodData(nameof(TestRows))] + public void AllRulesHaveActions(Abstractions.Loadouts.Synchronizers.Rules.Signature signature, string EnumShorthand, Optional disk, Optional prev, Optional loadout) + { + var action = ActionMapping.MapActions(signature); + action.Should().NotBe(0, "Every signature should have a corresponding action"); + + if (action.HasFlag(ExtractToDisk)) + signature.Should().HaveFlag(LoadoutArchived, "If we are extracting to disk, the loadout file should be archived"); + + if (action.HasFlag(WarnOfUnableToExtract)) + signature.Should().NotHaveFlag(LoadoutArchived, "If we are warning of unable to extract, the loadout file should not be archived"); + + if (action.HasFlag(DoNothing) && !signature.HasFlag(DiskExists)) + signature.Should().NotHaveFlag(LoadoutExists, "If we are doing nothing because the disk file does not exist, the loadout file should not exist"); + + if (action.HasFlag(DoNothing) && signature.HasFlag(DiskExists)) + { + signature.Should().HaveFlag(LoadoutExists, "If we are doing nothing because the disk file exists, the loadout file should exist") + .And.HaveFlag(DiskEqualsLoadout, "If we are doing nothing because the disk file exists, the loadout file should be the same as the disk file"); + } + } + + [Fact] + public async Task RulesAreAsExpected() + { + var allRules = AllSignatures().ToArray(); + + var sb = new StringBuilder(); + + sb.AppendLine("/// "); + sb.AppendLine($"/// Summary of all {allRules.Length} possible signatures in a somewhat readable format"); + sb.AppendLine("/// "); + + sb.AppendLine("public enum SignatureShorthand : ushort"); + sb.AppendLine("{"); + foreach (var row in allRules) + { + sb.AppendLine("\t/// "); + sb.AppendLine($"\t/// {row.Signature}"); + sb.AppendLine("\t/// "); + sb.AppendLine($"\t{row.EnumShorthand} = 0x{row.Signature.ToString("x")},"); + } + sb.AppendLine("}"); + + await Verify(sb.ToString()); + } + + + public static readonly Hash Hash1 = Hash.From(0xFAD01); + public static readonly Hash Hash2 = Hash.From(0xFAD02); + public static readonly Hash Hash3 = Hash.From(0xFAD03); + + public static IEnumerable TestRows() + { + return AllSignatures().Select(row => new object[] + { + row.Signature, row.EnumShorthand, row.Disk, row.Prev, row.Loadout, + } + ); + } + + public static IEnumerable<(Abstractions.Loadouts.Synchronizers.Rules.Signature Signature, string EnumShorthand, Optional Disk, Optional Prev, Optional Loadout)> AllSignatures() + { + + var options = + from disk in new[] { Optional.None(), Hash1, Hash2, Hash3 } + from prev in new[] { Optional.None(), Hash1, Hash2, Hash3 } + from loadout in new[] { Optional.None(), Hash1, Hash2, Hash3 } + from isIgnored in new[] { false, true } + from archivedState in new Hash[][] { [], [Hash1], [Hash2], [Hash3], [Hash1, Hash2], [Hash1, Hash3], [Hash2, Hash3], [Hash1, Hash2, Hash3] } + where disk.HasValue || prev.HasValue || loadout.HasValue + let sig = new SignatureBuilder + { + DiskHash = disk, + PrevHash = prev, + LoadoutHash = loadout, + DiskArchived = disk.HasValue && archivedState.Contains(disk.Value), + PrevArchived = prev.HasValue && archivedState.Contains(prev.Value), + LoadoutArchived = loadout.HasValue && archivedState.Contains(loadout.Value), + PathIsIgnored = isIgnored, + }.Build() + let enumShorthand = MakeShorthand(sig, disk, prev, loadout) + select (sig, enumShorthand, disk, prev, loadout); + + return options.DistinctBy(o => o.sig); + } + + private static string MakeShorthand(Signature sig, Optional disk, Optional prev, Optional loadout) + { + var diskCode = HashToValue(disk); + var prevCode = HashToValue(prev); + var loadoutCode = HashToValue(loadout); + + var archived = (disk.HasValue && sig.HasFlag(DiskArchived) ? "X" : "x") + + (prev.HasValue && sig.HasFlag(PrevArchived) ? "X" : "x") + + (loadout.HasValue && sig.HasFlag(LoadoutArchived) ? "X" : "x"); + + var ignored = sig.HasFlag(PathIsIgnored) ? "I" : "i"; + + return $"{diskCode}{prevCode}{loadoutCode}_{archived}_{ignored}"; + + } + + private static string HashToValue(Optional hash) + { + if (!hash.HasValue) return "x"; + + if (hash.Value == Hash1) return "A"; + if (hash.Value == Hash2) return "B"; + if (hash.Value == Hash3) return "C"; + throw new ArgumentOutOfRangeException(nameof(hash)); + } +} diff --git a/tests/NexusMods.DataModel.Tests/ToolTests.cs b/tests/NexusMods.DataModel.Tests/ToolTests.cs index 838ed0abf5..9e9b7ed449 100644 --- a/tests/NexusMods.DataModel.Tests/ToolTests.cs +++ b/tests/NexusMods.DataModel.Tests/ToolTests.cs @@ -1,11 +1,6 @@ -using EmptyFiles; using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; using NexusMods.Abstractions.GameLocators; -using NexusMods.Abstractions.Loadouts; -using NexusMods.Abstractions.Loadouts.Files; using NexusMods.Abstractions.Loadouts.Mods; -using NexusMods.DataModel.LoadoutSynchronizer.Extensions; using NexusMods.DataModel.Tests.Harness; using NexusMods.StandardGameLocators.TestHelpers;