From 688799419b3d55457c90f2f37a8b7c195c56d215 Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Mon, 11 May 2026 00:13:06 +0200 Subject: [PATCH 1/2] implement livevirtual strategy --- .../FileExistsStrategyTestBase.cs | 20 +- .../LiveVirtualFileExistsStrategyTests.cs | 356 ++++++++++++++++++ .../VirtualFileExistsStrategyBaseTests.cs | 122 ++++++ .../VirtualFileExistsStrategyTests.cs | 120 +----- ...leExistsStrategy_RootGameDirectoryTests.cs | 59 +++ .../WindowsFileExistsStrategyTests.cs | 8 + .../WineFileExistsStrategyTests.cs | 4 + .../PetroglyphFileSystemTests.UseStrategy.cs | 17 +- .../LiveVirtualFileExistsStrategy.cs | 159 ++++++++ .../FileExistStrategies/VirtualDirectory.cs | 11 +- .../VirtualFileExistsStrategy.cs | 158 +------- .../VirtualFileExistsStrategyBase.cs | 162 ++++++++ .../IO/PetroglyphFileSystem.Strategies.cs | 45 +++ 13 files changed, 965 insertions(+), 276 deletions(-) create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/LiveVirtualFileExistsStrategyTests.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategy_RootGameDirectoryTests.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/LiveVirtualFileExistsStrategy.cs create mode 100644 src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategyBase.cs diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs index feb8e62..005cc4a 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/FileExistsStrategyTestBase.cs @@ -3,6 +3,7 @@ using System.IO.Abstractions; using System.Runtime.InteropServices; using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; using PG.StarWarsGame.Engine.Utilities; using Testably.Abstractions; using Xunit; @@ -20,6 +21,21 @@ protected override IFileSystem CreateFileSystem() protected abstract override void ConfigureStrategy(PetroglyphFileSystem fs); + /// + /// Constructs a fresh, undisposed instance of the strategy under test, so generic suite + /// tests () can exercise it directly without + /// fighting the 's ownership of the active strategy. + /// + private protected abstract FileExistsStrategy CreateStrategyForDisposeTest(); + + [Fact] + public void Dispose_CalledTwice_DoesNotThrow() + { + var strategy = CreateStrategyForDisposeTest(); + strategy.Dispose(); + strategy.Dispose(); + } + protected virtual void AssertResolvedPath(string expectedOnDiskPath, string actualResult) { var expected = expectedOnDiskPath.Replace('\\', FileSystem.Path.DirectorySeparatorChar).Replace('/', FileSystem.Path.DirectorySeparatorChar); @@ -53,10 +69,6 @@ protected string NewTempDir() return dir; } - // --------------------------------------------------------------------------------------------- - // Shared tests — every strategy must satisfy. - // --------------------------------------------------------------------------------------------- - [Theory] [InlineData("/gameDir")] [InlineData(null)] diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/LiveVirtualFileExistsStrategyTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/LiveVirtualFileExistsStrategyTests.cs new file mode 100644 index 0000000..b47bead --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/LiveVirtualFileExistsStrategyTests.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Abstractions; +using System.Reflection; +using System.Threading.Tasks; +using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; + +#if Windows +public sealed class LiveVirtualFileExistsStrategy_Windows : LiveVirtualFileExistsStrategyTests +{ + protected override void ConfigureStrategy(PetroglyphFileSystem fs) + { + fs.UseLiveVirtualStrategy(new WineFileExistsStrategy(fs.UnderlyingFileSystem)); + } +} +#endif + +public sealed class LiveVirtualFileExistsStrategy_Wine : LiveVirtualFileExistsStrategyTests +{ + protected override void ConfigureStrategy(PetroglyphFileSystem fs) + { + fs.UseLiveVirtualStrategy(new WineFileExistsStrategy(fs.UnderlyingFileSystem)); + } +} + +public abstract class LiveVirtualFileExistsStrategyTests : VirtualFileExistsStrategyBaseTests +{ + /// + /// Hard cap on how long we'll wait for the OS to deliver a watcher event. The OS delivers + /// events asynchronously; we poll the cache state at until the + /// expected condition holds, only failing if the deadline passes. + /// + private static readonly TimeSpan WatcherEventTimeout = TimeSpan.FromSeconds(30); + + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50); + + private protected override void ConfigureStrategy(PetroglyphFileSystem fs, FileExistsStrategy underlying) + => fs.UseLiveVirtualStrategy(underlying); + + private protected override FileExistsStrategy CreateStrategyForDisposeTest() + => new LiveVirtualFileExistsStrategy(FileSystem, new WineFileExistsStrategy(FileSystem)); + + [Fact] + public async Task FileExists_AfterFileDeletedOnDisk_ReportsMissing() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + var file = FileSystem.Path.Combine(dataDir, "foo.xml"); + FileSystem.File.WriteAllText(file, "x"); + + Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); + + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(file), + () => !FileExists("Data/foo.xml".AsSpan(), dir.AsSpan()), + "snapshot to refresh after Data/foo.xml was deleted on disk"); + } + + [Fact] + public async Task FileExists_AfterFileCreatedOnDisk_ReportsPresent() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "seed.xml"), "x"); + + // Prime the snapshot. + Assert.True(FileExists("Data/seed.xml".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("Data/new.xml".AsSpan(), dir.AsSpan())); + + await AwaitCacheInvalidationAsync( + () => FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "new.xml"), "y"), + () => FileExists("Data/new.xml".AsSpan(), dir.AsSpan()), + "snapshot to refresh after Data/new.xml was created on disk"); + } + + [Fact] + public async Task FileExists_AfterFileRenamed_OldNameMissingNewNamePresent() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + var oldPath = FileSystem.Path.Combine(dataDir, "old.xml"); + var newPath = FileSystem.Path.Combine(dataDir, "new.xml"); + FileSystem.File.WriteAllText(oldPath, "x"); + + Assert.True(FileExists("Data/old.xml".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("Data/new.xml".AsSpan(), dir.AsSpan())); + + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Move(oldPath, newPath), + () => !FileExists("Data/old.xml".AsSpan(), dir.AsSpan()) + && FileExists("Data/new.xml".AsSpan(), dir.AsSpan()), + "snapshot to reflect the rename of Data/old.xml to Data/new.xml"); + } + + [Fact] + public async Task FileExists_AfterDirectoryRenamed_OldPathMissingNewPathPresent() + { + var dir = NewTempDir(); + var oldDir = FileSystem.Path.Combine(dir, "OldData"); + var newDir = FileSystem.Path.Combine(dir, "NewData"); + FileSystem.Directory.CreateDirectory(oldDir); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(oldDir, "foo.xml"), "x"); + + Assert.True(FileExists("OldData/foo.xml".AsSpan(), dir.AsSpan())); + + await AwaitCacheInvalidationAsync( + () => FileSystem.Directory.Move(oldDir, newDir), + () => !FileExists("OldData/foo.xml".AsSpan(), dir.AsSpan()) + && FileExists("NewData/foo.xml".AsSpan(), dir.AsSpan()), + "cached descendants of OldData to invalidate after directory rename"); + } + + [Fact] + public async Task FileExists_AfterDirectoryDeleted_AllDescendantsInvalidated() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + var subDir = FileSystem.Path.Combine(dataDir, "Sub"); + FileSystem.Directory.CreateDirectory(subDir); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "a.xml"), "1"); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(subDir, "b.xml"), "2"); + + Assert.True(FileExists("Data/a.xml".AsSpan(), dir.AsSpan())); + Assert.True(FileExists("Data/Sub/b.xml".AsSpan(), dir.AsSpan())); + + await AwaitCacheInvalidationAsync( + () => FileSystem.Directory.Delete(dataDir, recursive: true), + () => !FileExists("Data/a.xml".AsSpan(), dir.AsSpan()) + && !FileExists("Data/Sub/b.xml".AsSpan(), dir.AsSpan()), + "cached descendants of Data/ to invalidate after recursive directory delete"); + } + + [Fact] + public void SwapStrategy_LiveThenWineThenLive_FreshUnderlyingHandlesOutOfBaseLookups() + { + var root = NewTempDir(); + var gameDir = FileSystem.Path.Combine(root, "gameDir"); + var outsideDir = FileSystem.Path.Combine(root, "outside"); + FileSystem.Directory.CreateDirectory(gameDir); + FileSystem.Directory.CreateDirectory(outsideDir); + var insideFile = FileSystem.Path.Combine(gameDir, "in.xml"); + var outsideFile = FileSystem.Path.Combine(outsideDir, "out.xml"); + FileSystem.File.WriteAllText(insideFile, "i"); + FileSystem.File.WriteAllText(outsideFile, "o"); + + // First Live, with trackingA as the out-of-base fallback. + var trackingA = new TrackingFileExistsStrategy(FileSystem) { ReturnValue = true, ResolvedPath = outsideFile }; + PgFileSystem.UseLiveVirtualStrategy(trackingA); + + Assert.True(FileExists("in.xml".AsSpan(), gameDir.AsSpan())); // snapshot path, no delegation + Assert.True(FileExists(outsideFile.AsSpan(), gameDir.AsSpan())); // out-of-base → trackingA + Assert.Equal(1, trackingA.CallCount); + + // Swap to Wine. SwapStrategy disposes the previous Live, which also disposes trackingA. + PgFileSystem.UseWineStrategy(); + + // Swap back to Live with a brand-new tracking underlying. + var trackingB = new TrackingFileExistsStrategy(FileSystem) { ReturnValue = true, ResolvedPath = outsideFile }; + PgFileSystem.UseLiveVirtualStrategy(trackingB); + + // Out-of-base lookup must be routed through the NEW underlying. The old trackingA must + // not be touched anymore — this is the assertion that catches stale references. + Assert.True(FileExists(outsideFile.AsSpan(), gameDir.AsSpan())); + Assert.Equal(1, trackingA.CallCount); + Assert.Equal(1, trackingB.CallCount); + + // And the second Live owns its own snapshot store, so in-base lookups still bypass the + // underlying. + Assert.True(FileExists("in.xml".AsSpan(), gameDir.AsSpan())); + Assert.Equal(1, trackingB.CallCount); + } + + [Fact] + public async Task FileExists_TracksMultipleBaseDirectoriesIndependently() + { + var root = NewTempDir(); + var gameDir = FileSystem.Path.Combine(root, "gameDir"); + var workshopDir = FileSystem.Path.Combine(root, "workshops", "myMod"); + FileSystem.Directory.CreateDirectory(gameDir); + FileSystem.Directory.CreateDirectory(workshopDir); + var gameFile = FileSystem.Path.Combine(gameDir, "game.xml"); + var workshopFile = FileSystem.Path.Combine(workshopDir, "mod.xml"); + FileSystem.File.WriteAllText(gameFile, "x"); + FileSystem.File.WriteAllText(workshopFile, "y"); + + // Prime watchers for both base directories. + Assert.True(FileExists("game.xml".AsSpan(), gameDir.AsSpan())); + Assert.True(FileExists("mod.xml".AsSpan(), workshopDir.AsSpan())); + + // A change under the gameDir base must invalidate the gameDir snapshot… + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(gameFile), + () => !FileExists("game.xml".AsSpan(), gameDir.AsSpan()), + "gameDir snapshot to refresh after game.xml deleted"); + + // …but the workshop snapshot must still be live and serve mod.xml unchanged. + Assert.True(FileExists("mod.xml".AsSpan(), workshopDir.AsSpan())); + + // And the converse — deleting under workshopDir must update only that base's snapshot. + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(workshopFile), + () => !FileExists("mod.xml".AsSpan(), workshopDir.AsSpan()), + "workshopDir snapshot to refresh after mod.xml deleted"); + } + + [Fact] + public async Task FileExists_NewDirectoryUnderTrackedBase_FirstLookupSnapshotsThenCacheServes() + { + var dir = NewTempDir(); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dir, "seed.xml"), "x"); + + var tracking = new TrackingFileExistsStrategy(FileSystem); + ConfigureStrategy(PgFileSystem, tracking); + + // Prime the watcher on the base directory. + Assert.True(FileExists("seed.xml".AsSpan(), dir.AsSpan())); + + // Create a new directory + file under the watched base after the watcher is up. + var newDir = FileSystem.Path.Combine(dir, "NewDir"); + FileSystem.Directory.CreateDirectory(newDir); + var newFile = FileSystem.Path.Combine(newDir, "foo.xml"); + FileSystem.File.WriteAllText(newFile, "y"); + + // Wait for the watcher to invalidate the base directory's cache after the create. + await AwaitCacheInvalidationAsync( + () => { /* disk action already done */ }, + () => FileExists("NewDir/foo.xml".AsSpan(), dir.AsSpan()), + "first lookup of NewDir/foo.xml to succeed against the freshly-snapshotted directory"); + + var afterFirstLookup = tracking.CallCount; + + // Second lookup — the snapshot for NewDir is now in the store, so this is a cache hit. + // Neither the underlying tracking strategy nor the disk should be re-consulted. + Assert.True(FileExists("NewDir/foo.xml".AsSpan(), dir.AsSpan())); + Assert.Equal(afterFirstLookup, tracking.CallCount); + + // Underlying must never be called for in-base-dir paths regardless of lookup count. + Assert.Equal(0, tracking.CallCount); + } + + [Fact] + public async Task WatcherError_BrokenWatcher_StrategyRecoversAndKeepsTrackingOnNextLookup() + { + // There's no portable way to make a real FileSystemWatcher fire Error (buffer overflow + // is flaky/slow; root deletion is OS-dependent), so we synthesize the Error path by + // invoking the strategy's private handler with the live watcher as sender. We do not + // assert any internal state — only that the strategy keeps doing its job: lookups still + // resolve, and subsequent disk changes are still picked up via a re-armed watcher. + var baseDir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(baseDir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + var file = FileSystem.Path.Combine(dataDir, "foo.xml"); + FileSystem.File.WriteAllText(file, "x"); + + // Prime: the live strategy installs a watcher and snapshots the directory. + Assert.True(FileExists("Data/foo.xml".AsSpan(), baseDir.AsSpan())); + + var strategy = GetActiveLiveStrategy(); + InvokeOnWatcherError(strategy, GetWatchers(strategy)[baseDir], new ErrorEventArgs(new IOException("simulated"))); + + // 1) Lookups still resolve correctly after the Error. + Assert.True(FileExists("Data/foo.xml".AsSpan(), baseDir.AsSpan())); + + // 2) The strategy keeps tracking: a subsequent disk change is still reflected. + // (Implicitly verifies the next lookup re-armed a working watcher.) + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(file), + () => !FileExists("Data/foo.xml".AsSpan(), baseDir.AsSpan()), + "snapshot to invalidate after Data/foo.xml deleted (post-Error rebuild)"); + } + + [Fact] + public async Task WatcherError_OneOfManyRoots_OtherRootStillTracksChanges() + { + // An Error on one root must not impair the strategy's ability to track changes + // under unrelated roots, nor prevent the broken root from recovering on next use. + var root = NewTempDir(); + var gameDir = FileSystem.Path.Combine(root, "gameDir"); + var workshopDir = FileSystem.Path.Combine(root, "workshops", "myMod"); + FileSystem.Directory.CreateDirectory(gameDir); + FileSystem.Directory.CreateDirectory(workshopDir); + var gameFile = FileSystem.Path.Combine(gameDir, "g.xml"); + var workshopFile = FileSystem.Path.Combine(workshopDir, "m.xml"); + FileSystem.File.WriteAllText(gameFile, "g"); + FileSystem.File.WriteAllText(workshopFile, "m"); + + // Prime both bases. + Assert.True(FileExists("g.xml".AsSpan(), gameDir.AsSpan())); + Assert.True(FileExists("m.xml".AsSpan(), workshopDir.AsSpan())); + + // Synthesize an Error on the gameDir watcher only. + var strategy = GetActiveLiveStrategy(); + InvokeOnWatcherError(strategy, GetWatchers(strategy)[gameDir], new ErrorEventArgs(new IOException("simulated"))); + + // 1) The workshop watcher is still live — deleting a file there invalidates its cache. + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(workshopFile), + () => !FileExists("m.xml".AsSpan(), workshopDir.AsSpan()), + "workshop snapshot to invalidate after m.xml deleted; gameDir Error must not affect it"); + + // 2) The broken root still serves lookups (next call rebuilds snapshot + re-arms watcher). + Assert.True(FileExists("g.xml".AsSpan(), gameDir.AsSpan())); + + // 3) After re-arm, the gameDir watcher tracks changes again. + await AwaitCacheInvalidationAsync( + () => FileSystem.File.Delete(gameFile), + () => !FileExists("g.xml".AsSpan(), gameDir.AsSpan()), + "gameDir snapshot to invalidate after g.xml deleted (post-Error rebuild)"); + } + + private LiveVirtualFileExistsStrategy GetActiveLiveStrategy() + { + var field = typeof(PetroglyphFileSystem).GetField("_strategy", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (LiveVirtualFileExistsStrategy)field.GetValue(PgFileSystem)!; + } + + // GetWatchers / InvokeOnWatcherError exist only to *synthesize* an Error event (no portable + // way to make a real FSW fire one). The Error tests themselves assert observable behavior, + // not the watcher dictionary's contents. + private static Dictionary GetWatchers(LiveVirtualFileExistsStrategy strategy) + { + var field = typeof(LiveVirtualFileExistsStrategy).GetField("_watchers", BindingFlags.NonPublic | BindingFlags.Instance)!; + return (Dictionary)field.GetValue(strategy)!; + } + + private static void InvokeOnWatcherError(LiveVirtualFileExistsStrategy strategy, IFileSystemWatcher sender, ErrorEventArgs args) + { + var method = typeof(LiveVirtualFileExistsStrategy).GetMethod("OnWatcherError", BindingFlags.NonPublic | BindingFlags.Instance)!; + method.Invoke(strategy, [sender, args]); + } + + protected static async Task AwaitCacheInvalidationAsync(Action diskAction, Func predicate, string description) + { + diskAction(); + + var ct = TestContext.Current.CancellationToken; + var sw = Stopwatch.StartNew(); + while (true) + { + if (predicate()) + return; + if (sw.Elapsed >= WatcherEventTimeout) + Assert.Fail($"Timed out after {WatcherEventTimeout} waiting for: {description}"); + await Task.Delay(PollInterval, ct); + } + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs new file mode 100644 index 0000000..fcff8fb --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyBaseTests.cs @@ -0,0 +1,122 @@ +using System; +using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; +using PG.StarWarsGame.Engine.Utilities; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; + +/// +/// Tests every -derived strategy must satisfy: +/// per-directory snapshotting, no delegation for in-tree paths, delegation for out-of-tree +/// paths, and missing-directory handling. +/// +public abstract class VirtualFileExistsStrategyBaseTests : FileExistsStrategyTestBase +{ + /// + /// Switch the active strategy on to the strategy under test, with + /// as the fallback for outside-game-directory lookups. + /// + private protected abstract void ConfigureStrategy(PetroglyphFileSystem fs, FileExistsStrategy underlying); + + [Fact] + public void FileExists_RepeatedCallsSameDirectory_BothResolveFromSnapshot() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Mods", "Test", "Data", "Xml"); + FileSystem.Directory.CreateDirectory(dataDir); + var foo = FileSystem.Path.Combine(dataDir, "foo.xml"); + var bar = FileSystem.Path.Combine(dataDir, "bar.xml"); + FileSystem.File.WriteAllText(foo, "1"); + FileSystem.File.WriteAllText(bar, "2"); + + var sb1 = new ValueStringBuilder(); + Assert.True(PgFileSystem.FileExists("MODS/TEST/DATA/XML/FOO.XML".AsSpan(), ref sb1, dir.AsSpan())); + AssertResolvedPath(foo, sb1.ToString()); + + var sb2 = new ValueStringBuilder(); + Assert.True(PgFileSystem.FileExists("mods/test/data/xml/BAR.XML".AsSpan(), ref sb2, dir.AsSpan())); + AssertResolvedPath(bar, sb2.ToString()); + } + + [Fact] + public void FileExists_MissingDirectoryUnderGameRoot_RemainsMissing() + { + var dir = NewTempDir(); + FileSystem.Directory.CreateDirectory(FileSystem.Path.Combine(dir, "Mods", "Test", "Data", "Xml")); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dir, "Mods", "Test", "Data", "Xml", "foo.xml"), "1"); + + Assert.False(FileExists("MODS/TEST/DATA/OTHER/foo.xml".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("mods/test/data/other/bar.xml".AsSpan(), dir.AsSpan())); + } + + [Fact] + public void FileExists_PathOutsideGameDirectory_DelegatesToUnderlying() + { + var root = NewTempDir(); + var gameDir = FileSystem.Path.Combine(root, "game"); + var outsideDir = FileSystem.Path.Combine(root, "outside"); + FileSystem.Directory.CreateDirectory(gameDir); + FileSystem.Directory.CreateDirectory(outsideDir); + var file = FileSystem.Path.Combine(outsideDir, "FILE.TXT"); + FileSystem.File.WriteAllText(file, "x"); + + var tracking = new TrackingFileExistsStrategy(FileSystem) { ReturnValue = true, ResolvedPath = file }; + ConfigureStrategy(PgFileSystem, tracking); + + var sb = new ValueStringBuilder(); + Assert.True(PgFileSystem.FileExists(file.AsSpan(), ref sb, gameDir.AsSpan())); + AssertResolvedPath(file, sb.ToString()); + + Assert.Equal(1, tracking.CallCount); + } + + [Fact] + public void FileExists_PathUnderGameDirectory_DoesNotDelegate() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "foo.xml"), "x"); + + var tracking = new TrackingFileExistsStrategy(FileSystem); + ConfigureStrategy(PgFileSystem, tracking); + + Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); + Assert.Equal(0, tracking.CallCount); + } + + [Fact] + public void FileExists_RepeatedLookupInSnapshottedDirectory_DoesNotDelegate() + { + var dir = NewTempDir(); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "foo.xml"), "x"); + FileSystem.File.WriteAllText(FileSystem.Path.Combine(dataDir, "bar.xml"), "y"); + + var tracking = new TrackingFileExistsStrategy(FileSystem); + ConfigureStrategy(PgFileSystem, tracking); + + Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); + Assert.True(FileExists("Data/bar.xml".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("Data/missing.xml".AsSpan(), dir.AsSpan())); + + Assert.Equal(0, tracking.CallCount); + } + + [Fact] + public void FileExists_MissingSubdirectoryUnderGameRoot_DoesNotDelegate() + { + var dir = NewTempDir(); + FileSystem.Directory.CreateDirectory(FileSystem.Path.Combine(dir, "Data")); + + var tracking = new TrackingFileExistsStrategy(FileSystem); + ConfigureStrategy(PgFileSystem, tracking); + + Assert.False(FileExists("Data/Other/foo.xml".AsSpan(), dir.AsSpan())); + Assert.False(FileExists("Data/Other/bar.xml".AsSpan(), dir.AsSpan())); + + Assert.Equal(0, tracking.CallCount); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs index eb546dd..54e22ef 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategyTests.cs @@ -1,7 +1,6 @@ using System; -using System.IO; using PG.StarWarsGame.Engine.IO; -using PG.StarWarsGame.Engine.Utilities; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; using Xunit; namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; @@ -22,122 +21,29 @@ protected override void ConfigureStrategy(PetroglyphFileSystem fs) => fs.UseVirtualStrategy(); } -public abstract class VirtualFileExistsStrategyTests : FileExistsStrategyTestBase +public abstract class VirtualFileExistsStrategyTests : VirtualFileExistsStrategyBaseTests { - [Fact] - public void FileExists_RepeatedCallsSameDirectory_BothResolveFromSnapshot() - { - var dir = NewTempDir(); - var dataDir = Path.Combine(dir, "Mods", "Test", "Data", "Xml"); - Directory.CreateDirectory(dataDir); - var foo = Path.Combine(dataDir, "foo.xml"); - var bar = Path.Combine(dataDir, "bar.xml"); - File.WriteAllText(foo, "1"); - File.WriteAllText(bar, "2"); - - var sb1 = new ValueStringBuilder(); - Assert.True(PgFileSystem.FileExists("MODS/TEST/DATA/XML/FOO.XML".AsSpan(), ref sb1, dir.AsSpan())); - AssertResolvedPath(foo, sb1.ToString()); - - var sb2 = new ValueStringBuilder(); - Assert.True(PgFileSystem.FileExists("mods/test/data/xml/BAR.XML".AsSpan(), ref sb2, dir.AsSpan())); - AssertResolvedPath(bar, sb2.ToString()); - } + private protected override void ConfigureStrategy(PetroglyphFileSystem fs, FileExistsStrategy underlying) + => fs.UseVirtualStrategy(underlying); - [Fact] - public void FileExists_MissingDirectoryUnderGameRoot_RemainsMissing() - { - var dir = NewTempDir(); - Directory.CreateDirectory(Path.Combine(dir, "Mods", "Test", "Data", "Xml")); - File.WriteAllText(Path.Combine(dir, "Mods", "Test", "Data", "Xml", "foo.xml"), "1"); - - Assert.False(FileExists("MODS/TEST/DATA/OTHER/foo.xml".AsSpan(), dir.AsSpan())); - Assert.False(FileExists("mods/test/data/other/bar.xml".AsSpan(), dir.AsSpan())); - } + private protected override FileExistsStrategy CreateStrategyForDisposeTest() + => new VirtualFileExistsStrategy(FileSystem, new WineFileExistsStrategy(FileSystem)); [Fact] public void FileExists_AfterFirstResolve_SnapshotServesSubsequentLookups() { var dir = NewTempDir(); - var dataDir = Path.Combine(dir, "Data"); - Directory.CreateDirectory(dataDir); - var file = Path.Combine(dataDir, "foo.xml"); - File.WriteAllText(file, "x"); + var dataDir = FileSystem.Path.Combine(dir, "Data"); + FileSystem.Directory.CreateDirectory(dataDir); + var file = FileSystem.Path.Combine(dataDir, "foo.xml"); + FileSystem.File.WriteAllText(file, "x"); Assert.True(FileExists("DATA/foo.xml".AsSpan(), dir.AsSpan())); - File.Delete(file); + FileSystem.File.Delete(file); + // Non-live strategy: snapshot is taken once and serves all subsequent lookups even if + // the file is deleted on disk. The live variant overrides this behavior. Assert.True(FileExists("DATA/foo.xml".AsSpan(), dir.AsSpan())); } - - [Fact] - public void FileExists_PathOutsideGameDirectory_DelegatesToUnderlying() - { - var root = NewTempDir(); - var gameDir = Path.Combine(root, "game"); - var outsideDir = Path.Combine(root, "outside"); - Directory.CreateDirectory(gameDir); - Directory.CreateDirectory(outsideDir); - var file = Path.Combine(outsideDir, "FILE.TXT"); - File.WriteAllText(file, "x"); - - var tracking = new TrackingFileExistsStrategy(FileSystem) { ReturnValue = true, ResolvedPath = file }; - PgFileSystem.UseVirtualStrategy(tracking); - - var sb = new ValueStringBuilder(); - Assert.True(PgFileSystem.FileExists(file.AsSpan(), ref sb, gameDir.AsSpan())); - AssertResolvedPath(file, sb.ToString()); - - Assert.Equal(1, tracking.CallCount); - } - - [Fact] - public void FileExists_PathUnderGameDirectory_DoesNotDelegate() - { - var dir = NewTempDir(); - var dataDir = Path.Combine(dir, "Data"); - Directory.CreateDirectory(dataDir); - File.WriteAllText(Path.Combine(dataDir, "foo.xml"), "x"); - - var tracking = new TrackingFileExistsStrategy(FileSystem); - PgFileSystem.UseVirtualStrategy(tracking); - - Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); - Assert.Equal(0, tracking.CallCount); - } - - [Fact] - public void FileExists_RepeatedLookupInSnapshottedDirectory_DoesNotDelegate() - { - var dir = NewTempDir(); - var dataDir = Path.Combine(dir, "Data"); - Directory.CreateDirectory(dataDir); - File.WriteAllText(Path.Combine(dataDir, "foo.xml"), "x"); - File.WriteAllText(Path.Combine(dataDir, "bar.xml"), "y"); - - var tracking = new TrackingFileExistsStrategy(FileSystem); - PgFileSystem.UseVirtualStrategy(tracking); - - Assert.True(FileExists("Data/foo.xml".AsSpan(), dir.AsSpan())); - Assert.True(FileExists("Data/bar.xml".AsSpan(), dir.AsSpan())); - Assert.False(FileExists("Data/missing.xml".AsSpan(), dir.AsSpan())); - - Assert.Equal(0, tracking.CallCount); - } - - [Fact] - public void FileExists_MissingSubdirectoryUnderGameRoot_DoesNotDelegate() - { - var dir = NewTempDir(); - Directory.CreateDirectory(Path.Combine(dir, "Data")); - - var tracking = new TrackingFileExistsStrategy(FileSystem); - PgFileSystem.UseVirtualStrategy(tracking); - - Assert.False(FileExists("Data/Other/foo.xml".AsSpan(), dir.AsSpan())); - Assert.False(FileExists("Data/Other/bar.xml".AsSpan(), dir.AsSpan())); - - Assert.Equal(0, tracking.CallCount); - } } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategy_RootGameDirectoryTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategy_RootGameDirectoryTests.cs new file mode 100644 index 0000000..10e64e9 --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/VirtualFileExistsStrategy_RootGameDirectoryTests.cs @@ -0,0 +1,59 @@ +using System; +using System.IO.Abstractions; +using AnakinRaW.CommonUtilities.Testing.Attributes; +using Microsoft.Extensions.DependencyInjection; +using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.Utilities; +using Testably.Abstractions.Testing; +using Xunit; + +namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; + +/// +/// Exercises the snapshot path with the filesystem root (/) as the game directory. +/// Real-disk fixtures cannot create files at / without root privileges, so this uses +/// a Linux-simulated . Only meaningful for the non-live variant — +/// the live variant's binds to the real OS, not the mock. +/// +public sealed class VirtualFileExistsStrategy_RootGameDirectoryTests +{ + [PlatformSpecificFact(TestPlatformIdentifier.Linux)] + public void FileExists_GameDirectoryIsFilesystemRoot_ResolvesFromSnapshot() + { + var mockFs = new MockFileSystem(); + mockFs.File.WriteAllText("/foo.xml", "x"); + + var pgFs = NewPgFs(mockFs); + var tracking = new TrackingFileExistsStrategy(mockFs); + pgFs.UseVirtualStrategy(tracking); + + var sb = new ValueStringBuilder(); + Assert.True(pgFs.FileExists("/foo.xml".AsSpan(), ref sb, "/".AsSpan())); + Assert.Equal("/foo.xml", sb.ToString()); + + // Lookup is under the game directory, so it must resolve from the snapshot, not delegate. + Assert.Equal(0, tracking.CallCount); + } + + [PlatformSpecificFact(TestPlatformIdentifier.Linux)] + public void FileExists_GameDirectoryIsFilesystemRoot_MissingFile_ReportsFalseWithoutDelegating() + { + var mockFs = new MockFileSystem(); + mockFs.File.WriteAllText("/foo.xml", "x"); + + var pgFs = NewPgFs(mockFs); + var tracking = new TrackingFileExistsStrategy(mockFs); + pgFs.UseVirtualStrategy(tracking); + + var sb = new ValueStringBuilder(); + Assert.False(pgFs.FileExists("/missing.xml".AsSpan(), ref sb, "/".AsSpan())); + Assert.Equal(0, tracking.CallCount); + } + + private static PetroglyphFileSystem NewPgFs(IFileSystem fileSystem) + { + var sc = new ServiceCollection(); + sc.AddSingleton(fileSystem); + return new PetroglyphFileSystem(sc.BuildServiceProvider()); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs index 0ee13a7..98d11eb 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WindowsFileExistsStrategyTests.cs @@ -1,5 +1,6 @@ using System.Runtime.InteropServices; using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; using Xunit; namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; @@ -17,4 +18,11 @@ protected override void ConfigureStrategy(PetroglyphFileSystem fs) Assert.Skip("Windows strategy requires a Windows host."); fs.UseWindowsStrategy(); } + + private protected override FileExistsStrategy CreateStrategyForDisposeTest() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + Assert.Skip("Windows strategy requires a Windows host."); + return new WindowsFileExistsStrategy(FileSystem); + } } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs index 50e2aff..85a31fc 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/FileExistStrategies/WineFileExistsStrategyTests.cs @@ -1,4 +1,5 @@ using PG.StarWarsGame.Engine.IO; +using PG.StarWarsGame.Engine.IO.FileExistStrategies; namespace PG.StarWarsGame.Engine.FileSystem.Test.IO.FileExistStrategies; @@ -6,4 +7,7 @@ public sealed class WineFileExistsStrategyTests : FileExistsStrategyTestBase { protected override void ConfigureStrategy(PetroglyphFileSystem fs) => fs.UseWineStrategy(); + + private protected override FileExistsStrategy CreateStrategyForDisposeTest() + => new WineFileExistsStrategy(FileSystem); } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.UseStrategy.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.UseStrategy.cs index 243e432..d15f10c 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.UseStrategy.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem.Test/IO/PetroglyphFileSystemTests.UseStrategy.cs @@ -2,7 +2,6 @@ using System.IO.Abstractions; using System.Runtime.InteropServices; using AnakinRaW.CommonUtilities.Testing.Attributes; -using PG.StarWarsGame.Engine.Utilities; using Testably.Abstractions; using Xunit; @@ -61,6 +60,13 @@ public void UseVirtualStrategy_DefaultFallback_Resolves() AssertExists(); } + [Fact] + public void UseLiveVirtualStrategy_DefaultFallback_Resolves() + { + PgFileSystem.UseLiveVirtualStrategy(); + AssertExists(); + } + [PlatformSpecificFact(TestPlatformIdentifier.Windows)] public void UseWindowsStrategy_OnWindows_Resolves() { @@ -80,6 +86,12 @@ public void UseVirtualStrategy_WindowsFallback_OnNonWindows_Throws() Assert.Throws(() => PgFileSystem.UseVirtualStrategy(windowsFallback: true)); } + [PlatformSpecificFact(TestPlatformIdentifier.Linux)] + public void UseLiveVirtualStrategy_WindowsFallback_OnNonWindows_Throws() + { + Assert.Throws(() => PgFileSystem.UseLiveVirtualStrategy(windowsFallback: true)); + } + [Fact] public void Switching_BetweenStrategies_LeavesFileSystemUsable() { @@ -89,6 +101,9 @@ public void Switching_BetweenStrategies_LeavesFileSystemUsable() PgFileSystem.UseVirtualStrategy(); AssertExists(); + PgFileSystem.UseLiveVirtualStrategy(); + AssertExists(); + if (IsWindows) { PgFileSystem.UseWindowsStrategy(); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/LiveVirtualFileExistsStrategy.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/LiveVirtualFileExistsStrategy.cs new file mode 100644 index 0000000..12f835f --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/LiveVirtualFileExistsStrategy.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using AnakinRaW.CommonUtilities.FileSystem; +using PG.StarWarsGame.Engine.Utilities; + +namespace PG.StarWarsGame.Engine.IO.FileExistStrategies; + +internal sealed class LiveVirtualFileExistsStrategy(IFileSystem fileSystem, FileExistsStrategy underlying) + : VirtualFileExistsStrategyBase(fileSystem, underlying) +{ + private const int WatcherBufferSize = 64 * 1024; + + private readonly object _watchersLock = new(); + private readonly Dictionary _watchers = new(StringComparer.OrdinalIgnoreCase); + private bool _disposed; + + public override bool FileExists(ReadOnlySpan baseDirectory, ref ValueStringBuilder stringBuilder) + { + if (!baseDirectory.IsEmpty && FileSystem.Path.IsChildOf(baseDirectory, stringBuilder.AsSpan())) + EnsureWatcher(baseDirectory); + return base.FileExists(baseDirectory, ref stringBuilder); + } + + private void EnsureWatcher(ReadOnlySpan baseDirectory) + { + if (_disposed) + return; + + var rootStr = baseDirectory.ToString(); + if (!FileSystem.Directory.Exists(rootStr)) + return; + + lock (_watchersLock) + { + if (_disposed || _watchers.ContainsKey(rootStr)) + return; + + var watcher = FileSystem.FileSystemWatcher.New(rootStr); + watcher.IncludeSubdirectories = true; + watcher.NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName; + watcher.InternalBufferSize = WatcherBufferSize; + + watcher.Created += OnFileEvent; + watcher.Deleted += OnFileEvent; + watcher.Changed += OnFileEvent; + watcher.Renamed += OnFileRenamed; + watcher.Error += OnWatcherError; + + watcher.EnableRaisingEvents = true; + _watchers[rootStr] = watcher; + } + } + + public override void Dispose() + { + IFileSystemWatcher[] watchers; + lock (_watchersLock) + { + if (_disposed) + return; + _disposed = true; + watchers = new IFileSystemWatcher[_watchers.Count]; + _watchers.Values.CopyTo(watchers, 0); + _watchers.Clear(); + } + + foreach (var watcher in watchers) + TearDownWatcher(watcher); + + base.Dispose(); + } + + private void OnFileEvent(object sender, FileSystemEventArgs e) + { + InvalidatePathAndSubtree(e.FullPath); + } + + private void OnFileRenamed(object sender, RenamedEventArgs e) + { + InvalidatePathAndSubtree(e.OldFullPath); + InvalidatePathAndSubtree(e.FullPath); + } + + private void OnWatcherError(object sender, ErrorEventArgs e) + { + IFileSystemWatcher? broken = null; + string? brokenRoot = null; + lock (_watchersLock) + { + if (_disposed) + return; + foreach (var kv in _watchers) + { + if (ReferenceEquals(kv.Value, sender)) + { + broken = kv.Value; + brokenRoot = kv.Key; + break; + } + } + if (broken is null) + return; + _watchers.Remove(brokenRoot!); + } + + ClearCacheUnder(brokenRoot!); + TearDownWatcher(broken); + } + + private void TearDownWatcher(IFileSystemWatcher watcher) + { + watcher.EnableRaisingEvents = false; + watcher.Created -= OnFileEvent; + watcher.Deleted -= OnFileEvent; + watcher.Changed -= OnFileEvent; + watcher.Renamed -= OnFileRenamed; + watcher.Error -= OnWatcherError; + watcher.Dispose(); + } + + private void ClearCacheUnder(string root) + { + Store.TryRemove(root, out _); + var prefix = root.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) + ? root + : root + Path.DirectorySeparatorChar; + foreach (var key in Store.Keys) + { + if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + Store.TryRemove(key, out _); + } + } + + private void InvalidatePathAndSubtree(string fullPath) + { + InvalidateParentOf(fullPath); + InvalidateSubtree(fullPath); + } + + private void InvalidateParentOf(string fullPath) + { + var parent = FileSystem.Path.GetDirectoryName(fullPath); + if (parent is { Length: > 0 }) + Store.TryRemove(parent, out _); + } + + private void InvalidateSubtree(string fullPath) + { + Store.TryRemove(fullPath, out _); + var prefix = fullPath + Path.DirectorySeparatorChar; + foreach (var key in Store.Keys) + { + if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + Store.TryRemove(key, out _); + } + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualDirectory.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualDirectory.cs index f4f077c..dc0fc13 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualDirectory.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualDirectory.cs @@ -4,17 +4,14 @@ namespace PG.StarWarsGame.Engine.IO.FileExistStrategies; /// /// Immutable snapshot of a single directory's file listing. Files only — no subdirectory recursion. -/// Built once by and never mutated thereafter. /// -internal sealed class VirtualDirectory(string onDiskPath, Dictionary files) +internal sealed class VirtualDirectory(string onDiskPath, IReadOnlyDictionary files) { - /// The directory's path with the on-disk casing. + /// Gets the directory's path with the on-disk casing. public string OnDiskPath { get; } = onDiskPath; /// - /// Filename map. Keys compare case-insensitively (so callers can look up "FOO.XML" against - /// the on-disk "foo.xml") and the value carries the case-preserved on-disk filename used - /// when joining the result back into a full path. + /// Gets the filename map. /// - public Dictionary Files { get; } = files; + public IReadOnlyDictionary Files { get; } = files; } diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategy.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategy.cs index 5583ec7..a47b3f3 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategy.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategy.cs @@ -1,162 +1,6 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; using System.IO.Abstractions; -using AnakinRaW.CommonUtilities.FileSystem; -using PG.StarWarsGame.Engine.Utilities; namespace PG.StarWarsGame.Engine.IO.FileExistStrategies; internal sealed class VirtualFileExistsStrategy(IFileSystem fileSystem, FileExistsStrategy underlying) - : FileExistsStrategy(fileSystem) -{ - private readonly ConcurrentDictionary _store = - new(StringComparer.OrdinalIgnoreCase); - - public override void Dispose() - { - _store.Clear(); - underlying.Dispose(); - } - - public override bool FileExists(ReadOnlySpan baseDirectory, ref ValueStringBuilder stringBuilder) - { - var filePath = stringBuilder.AsSpan(); - - if (!IsUnderBaseDirectory(filePath, baseDirectory)) - return underlying.FileExists(baseDirectory, ref stringBuilder); - - var lastSep = filePath.LastIndexOf(Path.DirectorySeparatorChar); - if (lastSep <= 0) - return underlying.FileExists(baseDirectory, ref stringBuilder); - - var dirSpan = filePath.Slice(0, lastSep); - var fileName = filePath.Slice(lastSep + 1); - if (fileName.IsEmpty) - return underlying.FileExists(baseDirectory, ref stringBuilder); - - var dirKey = dirSpan.ToString(); - if (!_store.TryGetValue(dirKey, out var virtualDir)) - { - virtualDir = TrySnapshot(dirKey); - _store.TryAdd(dirKey, virtualDir); - } - - if (virtualDir is null) - return false; - - if (virtualDir.Files.TryGetValue(fileName.ToString(), out var onDiskName)) - { - stringBuilder.Length = 0; - stringBuilder.Append(virtualDir.OnDiskPath); - if (stringBuilder.Length > 0 && !LowLevelPath.IsDirectorySeparator(stringBuilder[stringBuilder.Length - 1])) - stringBuilder.Append(Path.DirectorySeparatorChar); - stringBuilder.Append(onDiskName); - return true; - } - - return false; - } - - private VirtualDirectory? TrySnapshot(string inputDirPath) - { - var onDiskPath = TryResolveDirectory(inputDirPath); - if (onDiskPath is null) - return null; - - var files = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var entry in FileSystem.Directory.EnumerateFiles(onDiskPath)) - { - var name = FileSystem.Path.GetFileName(entry); - files[name] = name; - } - return new VirtualDirectory(onDiskPath, files); - } - - private string? TryResolveDirectory(string dirPath) - { - if (string.IsNullOrEmpty(dirPath)) - return null; - - if (FileSystem.Directory.Exists(dirPath)) - return dirPath; - - var path = dirPath.AsSpan(); - var rootLen = FileSystem.Path.GetPathRoot(path).Length; - if (rootLen == 0) - return null; - - var currentDir = dirPath.Substring(0, rootLen); - if (!FileSystem.Directory.Exists(currentDir)) - return null; - - var sb = new ValueStringBuilder(stackalloc char[260]); - try - { - sb.Append(currentDir); - - var pos = rootLen; - if (pos < path.Length && path[pos] == Path.DirectorySeparatorChar) - pos++; - - while (pos < path.Length) - { - var rest = path.Slice(pos); - var nextSlash = rest.IndexOf(Path.DirectorySeparatorChar); - var componentEnd = nextSlash >= 0 ? pos + nextSlash : path.Length; - var component = path.Slice(pos, componentEnd - pos); - - if (component.IsEmpty) - { - pos = componentEnd + 1; - continue; - } - - var savedLen = sb.Length; - if (savedLen == 0 || !LowLevelPath.IsDirectorySeparator(sb[savedLen - 1])) - sb.Append(Path.DirectorySeparatorChar); - sb.Append(component); - - var literalPath = sb.AsSpan().ToString(); - if (FileSystem.Directory.Exists(literalPath)) - { - currentDir = literalPath; - pos = componentEnd + 1; - continue; - } - - sb.Length = savedLen; - - var found = false; - foreach (var entry in FileSystem.Directory.EnumerateDirectories(currentDir)) - { - if (FileSystem.Path.GetFileName(entry.AsSpan()).Equals(component, StringComparison.OrdinalIgnoreCase)) - { - sb.Length = 0; - sb.Append(entry); - currentDir = entry; - found = true; - break; - } - } - - if (!found) - return null; - - pos = componentEnd + 1; - } - - return currentDir; - } - finally - { - sb.Dispose(); - } - } - - private bool IsUnderBaseDirectory(ReadOnlySpan path, ReadOnlySpan gameDirectory) - { - return !gameDirectory.IsEmpty && FileSystem.Path.IsChildOf(gameDirectory, path); - } -} + : VirtualFileExistsStrategyBase(fileSystem, underlying); diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategyBase.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategyBase.cs new file mode 100644 index 0000000..b6b685b --- /dev/null +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/FileExistStrategies/VirtualFileExistsStrategyBase.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO.Abstractions; +using AnakinRaW.CommonUtilities.FileSystem; +using PG.StarWarsGame.Engine.Utilities; + +namespace PG.StarWarsGame.Engine.IO.FileExistStrategies; + +internal abstract class VirtualFileExistsStrategyBase(IFileSystem fileSystem, FileExistsStrategy underlying) + : FileExistsStrategy(fileSystem) +{ + protected readonly ConcurrentDictionary Store = + new(StringComparer.OrdinalIgnoreCase); + + protected readonly FileExistsStrategy Underlying = underlying; + + public override void Dispose() + { + Store.Clear(); + Underlying.Dispose(); + } + + public override bool FileExists(ReadOnlySpan baseDirectory, ref ValueStringBuilder stringBuilder) + { + var filePath = stringBuilder.AsSpan(); + + if (!IsUnderGameDirectory(filePath, baseDirectory)) + return Underlying.FileExists(baseDirectory, ref stringBuilder); + + var fileName = FileSystem.Path.GetFileName(filePath); + if (fileName.IsEmpty) + return false; + + var dirSpan = FileSystem.Path.GetDirectoryName(filePath); + if (dirSpan.IsEmpty) + return Underlying.FileExists(baseDirectory, ref stringBuilder); + + var dirKey = dirSpan.ToString(); + if (!Store.TryGetValue(dirKey, out var virtualDir)) + { + virtualDir = TrySnapshot(dirKey); + Store.TryAdd(dirKey, virtualDir); + } + + if (virtualDir is null) + return false; + + if (virtualDir.Files.TryGetValue(fileName.ToString(), out var onDiskName)) + { + stringBuilder.Length = 0; + stringBuilder.Append(virtualDir.OnDiskPath); + if (stringBuilder.Length > 0 && !LowLevelPath.IsDirectorySeparator(stringBuilder[stringBuilder.Length - 1])) + stringBuilder.Append(FileSystem.Path.DirectorySeparatorChar); + stringBuilder.Append(onDiskName); + return true; + } + + return false; + } + + private VirtualDirectory? TrySnapshot(string inputDirPath) + { + var onDiskPath = TryResolveDirectory(inputDirPath); + if (onDiskPath is null) + return null; + + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in FileSystem.Directory.EnumerateFiles(onDiskPath)) + { + var name = FileSystem.Path.GetFileName(entry); + files[name] = name; + } + return new VirtualDirectory(onDiskPath, files); + } + + private string? TryResolveDirectory(string dirPath) + { + if (string.IsNullOrEmpty(dirPath)) + return null; + + if (FileSystem.Directory.Exists(dirPath)) + return dirPath; + + var path = dirPath.AsSpan(); + var rootLen = FileSystem.Path.GetPathRoot(path).Length; + if (rootLen == 0) + return null; + + var currentDir = dirPath.Substring(0, rootLen); + if (!FileSystem.Directory.Exists(currentDir)) + return null; + + var sb = new ValueStringBuilder(stackalloc char[260]); + try + { + sb.Append(currentDir); + + var pos = rootLen; + if (pos < path.Length && path[pos] == FileSystem.Path.DirectorySeparatorChar) + pos++; + + while (pos < path.Length) + { + var rest = path.Slice(pos); + var nextSlash = rest.IndexOf(FileSystem.Path.DirectorySeparatorChar); + var componentEnd = nextSlash >= 0 ? pos + nextSlash : path.Length; + var component = path.Slice(pos, componentEnd - pos); + + if (component.IsEmpty) + { + pos = componentEnd + 1; + continue; + } + + var savedLen = sb.Length; + if (savedLen == 0 || !LowLevelPath.IsDirectorySeparator(sb[savedLen - 1])) + sb.Append(FileSystem.Path.DirectorySeparatorChar); + sb.Append(component); + + var literalPath = sb.AsSpan().ToString(); + if (FileSystem.Directory.Exists(literalPath)) + { + currentDir = literalPath; + pos = componentEnd + 1; + continue; + } + + sb.Length = savedLen; + + var found = false; + foreach (var entry in FileSystem.Directory.EnumerateDirectories(currentDir)) + { + if (FileSystem.Path.GetFileName(entry.AsSpan()).Equals(component, StringComparison.OrdinalIgnoreCase)) + { + sb.Length = 0; + sb.Append(entry); + currentDir = entry; + found = true; + break; + } + } + + if (!found) + return null; + + pos = componentEnd + 1; + } + + return currentDir; + } + finally + { + sb.Dispose(); + } + } + + private bool IsUnderGameDirectory(ReadOnlySpan path, ReadOnlySpan gameDirectory) + { + return !gameDirectory.IsEmpty && FileSystem.Path.IsChildOf(gameDirectory, path); + } +} diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs index c7da143..9136af2 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs @@ -59,6 +59,51 @@ public void UseVirtualStrategy(bool? windowsFallback = null) internal void UseVirtualStrategy(FileExistsStrategy underlying) => SwapStrategy(new VirtualFileExistsStrategy(_underlyingFileSystem, underlying)); + /// + /// Switches the active file-exists strategy to a snapshot-based one that refreshes itself when + /// files are added, removed, or renamed in the game directory. + /// + /// + /// + /// Equivalent to for lookups, but lazily attaches a + /// recursive to every distinct base directory passed + /// to . Each watcher's events invalidate cached directory listings under its + /// root on demand; the next lookup rebuilds the affected snapshot from disk. File + /// content changes are not tracked. + /// + /// + /// Each watcher is created on the first lookup that lands inside its base directory and is torn + /// down when the strategy is replaced or the file system is disposed. If a watcher's internal + /// buffer overflows or the OS otherwise reports an error, only that watcher is removed and only + /// its subtree is evicted from the cache; other roots continue to be tracked. + /// + /// + /// On Linux, each watcher consumes one inotify slot per directory in its subtree (per-user + /// kernel limit, fs.inotify.max_user_watches). Consumers tracking many large trees may + /// need to raise this limit. + /// + /// + /// + /// to delegate outside-game-directory lookups to the Windows + /// strategy; to delegate them to the Wine search + /// engine; to pick the Windows strategy on Windows hosts and the Wine + /// strategy otherwise. + /// + /// + /// is and the host is not Windows. + /// + public void UseLiveVirtualStrategy(bool? windowsFallback = null) + { + var useWindows = windowsFallback ?? RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + FileExistsStrategy fallback = useWindows + ? CreateWindowsStrategy() + : new WineFileExistsStrategy(_underlyingFileSystem); + UseLiveVirtualStrategy(fallback); + } + + internal void UseLiveVirtualStrategy(FileExistsStrategy underlying) + => SwapStrategy(new LiveVirtualFileExistsStrategy(_underlyingFileSystem, underlying)); + private WindowsFileExistsStrategy CreateWindowsStrategy() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) From c3702a9f0901933be6e0a22c6fdfd8981f5e294f Mon Sep 17 00:00:00 2001 From: AnakinRaW Date: Mon, 11 May 2026 00:21:23 +0200 Subject: [PATCH 2/2] fix documentation make make project prod ready --- .../IO/PetroglyphFileSystem.CombineJoin.cs | 21 +++++++++++++++++++ .../IO/PetroglyphFileSystem.Strategies.cs | 4 ++-- .../PG.StarWarsGame.Engine.FileSystem.csproj | 2 +- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs index d9f3cd8..89de4f3 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.CombineJoin.cs @@ -6,6 +6,27 @@ namespace PG.StarWarsGame.Engine.IO; public sealed partial class PetroglyphFileSystem { + /// + /// Combines strings into a path. + /// + /// + /// + /// This method is intended to concatenate individual strings into a single string that represents a file path. + /// However, if an argument other than the first contains a rooted path, any previous path components are ignored, + /// and the returned string begins with that rooted path component. + /// + /// + /// This method supports the directory separator characters ("/") and ("\"). + /// + /// + /// The first path to combine. + /// The second path to combine. + /// + /// The combined paths. If one of the specified paths is a zero-length string, this method returns the other path. + /// If contains an absolute path, this method returns . + /// + /// or is . + /// public string CombinePath(string pathA, string pathB) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs index 9136af2..b6727e7 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/IO/PetroglyphFileSystem.Strategies.cs @@ -23,9 +23,9 @@ public sealed partial class PetroglyphFileSystem /// /// /// Selecting this strategy directly is rarely correct. Prefer - /// on non-Windows hosts and + /// on non-Windows hosts and /// on Windows. This method exists primarily to support the search engine used internally by - /// for paths outside the game directory. + /// for paths outside the game directory. /// /// Provides full mediation: every lookup re-walks the path with no caching. /// diff --git a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj index 02421b1..07c768d 100644 --- a/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj +++ b/src/PetroglyphTools/PG.StarWarsGame.Engine.FileSystem/PG.StarWarsGame.Engine.FileSystem.csproj @@ -8,7 +8,7 @@ alamo,petroglyph,glyphx - + true true true