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.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 c7da143..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.
///
@@ -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))
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