using System.Text; using StarterNG.Application.Abstractions; namespace StarterNG.Tests.Fakes; /// /// An over a dictionary, so a whole MaSzyna /// installation can be described inline in a test. /// /// /// Paths are normalised to forward slashes and compared case-insensitively, /// matching how the parsers treat installation paths on both platforms - or, /// asked for, case-sensitively, as a Linux disk holds them. /// public sealed class InMemoryFileSystem : IFileSystem { private readonly StringComparison _comparison; private readonly Dictionary _files; private readonly HashSet _directories; private readonly HashSet _executable; private readonly Dictionary _links; private readonly HashSet _unreadable; private readonly HashSet _unlistable; private readonly Dictionary _written; public InMemoryFileSystem(bool caseSensitive = false) { _comparison = caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; var comparer = StringComparer.FromComparison(_comparison); _files = new Dictionary(comparer); _directories = new HashSet(comparer); _executable = new HashSet(comparer); _links = new Dictionary(comparer); _unreadable = new HashSet(comparer); _unlistable = new HashSet(comparer); _written = new Dictionary(comparer); } public DateTime LastWriteTimeUtc { get; set; } = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); /// Making a link throws, as on a disk that cannot hold one. public bool LinksFail { get; set; } /// A folder moved to a destination this says yes to, its path with forward slashes, throws. public Func? MoveFails { get; set; } /// The file stays, but reading or copying it throws, as a locked one does; writing it still goes through. public InMemoryFileSystem FailReading(string path) { _unreadable.Add(Normalize(path)); return this; } public InMemoryFileSystem AllowReading(string path) { _unreadable.Remove(Normalize(path)); return this; } /// The folder is there, but listing it throws, as one without the permission does. public InMemoryFileSystem DenyListing(string path) { _unlistable.Add(Normalize(path)); return this; } static InMemoryFileSystem() { // The game's own files are code page 1250; a test that writes Polish text // into one needs the provider registered just as the application does. Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); } /// Adds a UTF-8 text file, creating its parent directories. public InMemoryFileSystem WithFile(string path, string contents) => WithFile(path, contents, Encoding.UTF8); /// Adds a text file in a given encoding, as the game's files are stored. public InMemoryFileSystem WithFile(string path, string contents, Encoding encoding) { string key = Normalize(path); _files[key] = encoding.GetBytes(contents); AddParents(key); return this; } /// Adds a file in the code page the .scn and .fiz formats use. public InMemoryFileSystem WithLegacyFile(string path, string contents) => WithFile(path, contents, Encoding.GetEncoding(1250)); /// Adds an empty directory. public InMemoryFileSystem WithDirectory(string path) { string key = Normalize(path); _directories.Add(key); AddParents(key); return this; } public bool FileExists(string path) => _files.ContainsKey(Normalize(path)); /// A link counts as the directory it points at, as the disk has it. public bool DirectoryExists(string path) => _directories.Contains(Normalize(path)) || (_links.TryGetValue(Normalize(path), out string? target) && _directories.Contains(Normalize(target))); public string ReadAllText(string path) => Encoding.UTF8.GetString(Bytes(path)); public string ReadAllText(string path, Encoding encoding) => encoding.GetString(Bytes(path)); public string[] ReadAllLines(string path) => ReadAllText(path).Split('\n').Select(line => line.TrimEnd('\r')).ToArray(); public byte[] ReadAllBytes(string path) => Bytes(path); public Stream OpenRead(string path) => new MemoryStream(Bytes(path), writable: false); public void WriteAllText(string path, string contents) => WithFile(path, contents); public void WriteAllText(string path, string contents, Encoding encoding) { string key = Normalize(path); _files[key] = encoding.GetBytes(contents); AddParents(key); } public void WriteAllTextAtomic(string path, string contents) => WithFile(path, contents); public void WriteAllBytesAtomic(string path, byte[] bytes) => WriteAllBytes(path, bytes); public void WriteAllLines(string path, IEnumerable lines) => WithFile(path, string.Join("\n", lines)); public void WriteAllBytes(string path, byte[] bytes) { string key = Normalize(path); _files[key] = bytes; AddParents(key); } public void CreateDirectory(string path) => WithDirectory(path); public void DeleteFile(string path) => _files.Remove(Normalize(path)); public void CopyFile(string sourcePath, string destinationPath, bool overwrite) { string destination = Normalize(destinationPath); if (!overwrite && _files.ContainsKey(destination)) throw new IOException($"{destinationPath} already exists"); WriteAllBytes(destinationPath, Bytes(sourcePath)); } public IReadOnlyList GetFiles(string path, string searchPattern = "*") { string prefix = Normalize(path) + "/"; var pattern = ToRegex(searchPattern); return _files.Keys .Where(key => key.StartsWith(prefix, _comparison)) .Where(key => !key[prefix.Length..].Contains('/')) .Where(key => pattern.IsMatch(key[prefix.Length..])) .OrderBy(key => key, StringComparer.OrdinalIgnoreCase) .ToList(); } public IReadOnlyList GetFilesRecursive(string path, string searchPattern) { string prefix = Normalize(path) + "/"; var pattern = ToRegex(searchPattern); return _files.Keys .Where(key => key.StartsWith(prefix, _comparison)) .Where(key => !_unlistable.Any(folder => key.StartsWith(folder + "/", _comparison))) .Where(key => pattern.IsMatch(key[(key.LastIndexOf('/') + 1)..])) .OrderBy(key => key, StringComparer.OrdinalIgnoreCase) .ToList(); } public IReadOnlyList GetDirectories(string path) { if (_unlistable.Contains(Normalize(path))) throw new UnauthorizedAccessException($"Access to the path '{path}' is denied."); string prefix = Normalize(path) + "/"; return _directories .Where(key => key.StartsWith(prefix, _comparison)) .Where(key => !key[prefix.Length..].Contains('/')) .OrderBy(key => key, StringComparer.OrdinalIgnoreCase) .ToList(); } public DateTime GetLastWriteTimeUtc(string path) => _written.TryGetValue(Normalize(path), out var written) ? written : LastWriteTimeUtc; /// One file's own time, in place of . public InMemoryFileSystem WrittenAt(string path, DateTime utc) { _written[Normalize(path)] = utc; return this; } /// Files added through carry the execute bit. public long GetFileSize(string path) => Bytes(path).LongLength; public bool IsExecutable(string path) => _executable.Contains(Normalize(path)); public InMemoryFileSystem WithExecutable(string path, string contents = "\u007fELF") { WithFile(path, contents); _executable.Add(Normalize(path)); return this; } public string? LinkTarget(string path) => _links.GetValueOrDefault(Normalize(path)); public void CreateDirectoryLink(string path, string target) { if (LinksFail) throw new IOException($"cannot link {path}"); string key = Normalize(path); if (_links.ContainsKey(key) || _directories.Contains(key) || _files.ContainsKey(key)) throw new IOException($"{path} already exists"); _links[key] = target; AddParents(key); } public void MoveDirectory(string sourcePath, string destinationPath) { if (MoveFails?.Invoke(Normalize(destinationPath)) == true) throw new IOException($"cannot move to {destinationPath}"); string from = Normalize(sourcePath), to = Normalize(destinationPath); if (!_directories.Contains(from) && !_links.ContainsKey(from)) throw new DirectoryNotFoundException(sourcePath); if (_directories.Contains(to) || _links.ContainsKey(to) || _files.ContainsKey(to)) throw new IOException($"{destinationPath} already exists"); string? Moved(string key) => string.Equals(key, from, _comparison) ? to : key.StartsWith(from + "/", _comparison) ? to + key[from.Length..] : null; foreach (var (key, bytes) in _files.ToList()) if (Moved(key) is { } moved) { _files.Remove(key); _files[moved] = bytes; } foreach (string key in _executable.ToList()) if (Moved(key) is { } moved) { _executable.Remove(key); _executable.Add(moved); } foreach (var (key, target) in _links.ToList()) if (Moved(key) is { } moved) { _links.Remove(key); _links[moved] = target; } foreach (string key in _directories.ToList()) if (Moved(key) is { } moved) { _directories.Remove(key); _directories.Add(moved); } AddParents(to); } private byte[] Bytes(string path) { if (_unreadable.Contains(Normalize(path))) throw new IOException($"The process cannot access the file '{path}'."); return _files.TryGetValue(Normalize(path), out var bytes) ? bytes : throw new FileNotFoundException(path); } private void AddParents(string key) { int slash = key.LastIndexOf('/'); while (slash > 0) { key = key[..slash]; if (!_directories.Add(key)) return; slash = key.LastIndexOf('/'); } } private static string Normalize(string path) => path.Replace('\\', '/').TrimEnd('/'); private static System.Text.RegularExpressions.Regex ToRegex(string searchPattern) => new("^" + System.Text.RegularExpressions.Regex.Escape(searchPattern) .Replace("\\*", ".*").Replace("\\?", ".") + "$", System.Text.RegularExpressions.RegexOptions.IgnoreCase); }