diff --git a/StarterNG.Tests/ConfigFileTests.cs b/StarterNG.Tests/ConfigFileTests.cs new file mode 100644 index 0000000..1eb356b --- /dev/null +++ b/StarterNG.Tests/ConfigFileTests.cs @@ -0,0 +1,161 @@ +using StarterNG.Classes; + +namespace StarterNG.Tests; + +public class ConfigFileTests +{ + private static string[] Lines(ConfigFile config) => + config.ToText().Split(Environment.NewLine); + + [Fact] + public void A_file_is_written_with_the_hosts_line_endings_whichever_it_came_with() + { + // parser.cpp breaks words on \r as on \n, so the simulator takes either. + string nl = Environment.NewLine; + + Assert.Equal($"a 1{nl}b 2{nl}", ConfigFile.Parse("a 1\nb 2\n").ToText()); + Assert.Equal($"a 1{nl}b 2{nl}", ConfigFile.Parse("a 1\r\nb 2\r\n").ToText()); + } + + [Fact] + public void Removing_a_key_comments_every_line_that_sets_it() + { + var config = ConfigFile.Parse("width 800\nheight 600\nwidth 1024\nWIDTH 1920\n"); + + config.Remove("width"); + + Assert.False(config.Has("width")); + Assert.Equal(new[] { "// width 800", "height 600", "// width 1024", "// WIDTH 1920", "" }, Lines(config)); + // Parsed again, nothing of it comes back. + Assert.False(ConfigFile.Parse(config.ToText()).Has("width")); + } + + [Fact] + public void A_removed_key_keeps_its_comment_and_indent() + { + var config = ConfigFile.Parse(" width 800\t// mine\n"); + + config.Remove("width"); + + Assert.Equal(" // width 800\t// mine", Lines(config)[0]); + } + + [Fact] + public void Removing_a_key_leaves_what_only_mentions_it() + { + var config = ConfigFile.Parse("// width 800\nwidth.max 900\nwidth 1024\n"); + + config.Remove("width"); + + Assert.Equal(new[] { "// width 800", "width.max 900", "// width 1024", "" }, Lines(config)); + } + + [Fact] + public void A_new_key_goes_before_endconfig() + { + // Globals.cpp stops reading at endconfig. + var config = ConfigFile.Parse("width 800\nendconfig\n// notes\n"); + + config.Set("height", "600"); + config.Set("fullscreen", "no"); + + Assert.Equal(new[] { "width 800", "height 600", "fullscreen no", "endconfig", "// notes", "" }, Lines(config)); + } + + [Theory] + [InlineData("endconfig")] + [InlineData("ENDCONFIG")] + [InlineData(" EndConfig // the end")] + public void Endconfig_is_known_as_the_simulator_knows_it(string line) + { + // Its parser lower-cases every word. + var config = ConfigFile.Parse(line + "\n"); + + config.Set("width", "800"); + + Assert.Equal("width 800", Lines(config)[0]); + } + + [Fact] + public void Endconfig_in_a_comment_ends_nothing() + { + var config = ConfigFile.Parse("// endconfig\n"); + + config.Set("width", "800"); + + Assert.Equal(new[] { "// endconfig", "", "width 800" }, Lines(config)); + } + + [Fact] + public void Keys_found_after_an_insertion_are_still_the_right_lines() + { + var config = ConfigFile.Parse("width 800\nendconfig\nlate 1\n"); + + config.Set("height", "600"); + config.Set("width", "1024"); + config.Set("height", "720"); + + Assert.Equal(new[] { "width 1024", "height 720", "endconfig", "late 1", "" }, Lines(config)); + Assert.Equal("1024", config.GetRaw("width")); + Assert.Equal("720", config.GetRaw("height")); + Assert.Equal("1", config.GetRaw("late")); + } + + [Fact] + public void A_key_past_endconfig_is_read_but_known_to_be_past_it() + { + var config = ConfigFile.Parse("width 800\nendconfig\nheight 600\n"); + + Assert.Equal("600", config.GetRaw("height")); + Assert.True(config.IsPastEnd("height")); + Assert.False(config.IsPastEnd("width")); + Assert.False(config.IsPastEnd("endconfig")); + Assert.False(config.IsPastEnd("absent")); + } + + [Fact] + public void A_key_set_past_endconfig_moves_before_it_with_its_comment() + { + var config = ConfigFile.Parse("endconfig\n height 600\t// mine\nlate 1\n"); + + config.Set("height", "720"); + + Assert.Equal(new[] { " height 720\t// mine", "endconfig", "late 1", "" }, Lines(config)); + Assert.False(config.IsPastEnd("height")); + Assert.True(config.IsPastEnd("late")); + } + + [Fact] + public void Of_a_key_on_both_sides_of_endconfig_the_line_the_simulator_reads_counts() + { + var config = ConfigFile.Parse("width 800\nendconfig\nwidth 1920\n"); + + Assert.Equal("800", config.GetRaw("width")); + Assert.False(config.IsPastEnd("width")); + + config.Set("width", "1024"); + + Assert.Equal(new[] { "width 1024", "endconfig", "width 1920", "" }, Lines(config)); + } + + [Fact] + public void A_key_past_endconfig_is_removed_like_any_other() + { + var config = ConfigFile.Parse("width 800\nendconfig\nwidth 1920\nheight 600\n"); + + config.Remove("width"); + config.Remove("height"); + + Assert.Equal(new[] { "// width 800", "endconfig", "// width 1920", "// height 600", "" }, Lines(config)); + } + + [Fact] + public void Without_endconfig_a_new_key_goes_at_the_end_as_it_did() + { + var config = ConfigFile.Parse("width 800"); + + config.Set("height", "600"); + + Assert.Equal(new[] { "width 800", "height 600" }, Lines(config)); + } +} diff --git a/StarterNG.Tests/Fakes/InMemoryFileSystem.cs b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs index fca2cbd..e95f021 100644 --- a/StarterNG.Tests/Fakes/InMemoryFileSystem.cs +++ b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs @@ -9,16 +9,61 @@ namespace StarterNG.Tests.Fakes; /// /// /// Paths are normalised to forward slashes and compared case-insensitively, -/// matching how the parsers treat installation paths on both platforms. +/// 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 Dictionary _files = new(StringComparer.OrdinalIgnoreCase); - private readonly HashSet _directories = new(StringComparer.OrdinalIgnoreCase); - private readonly HashSet _executable = new(StringComparer.OrdinalIgnoreCase); + 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 @@ -54,7 +99,10 @@ public sealed class InMemoryFileSystem : IFileSystem public bool FileExists(string path) => _files.ContainsKey(Normalize(path)); - public bool DirectoryExists(string path) => _directories.Contains(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)); @@ -76,6 +124,10 @@ public sealed class InMemoryFileSystem : IFileSystem 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)); @@ -103,7 +155,7 @@ public sealed class InMemoryFileSystem : IFileSystem string prefix = Normalize(path) + "/"; var pattern = ToRegex(searchPattern); return _files.Keys - .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Where(key => key.StartsWith(prefix, _comparison)) .Where(key => !key[prefix.Length..].Contains('/')) .Where(key => pattern.IsMatch(key[prefix.Length..])) .OrderBy(key => key, StringComparer.OrdinalIgnoreCase) @@ -115,7 +167,8 @@ public sealed class InMemoryFileSystem : IFileSystem string prefix = Normalize(path) + "/"; var pattern = ToRegex(searchPattern); return _files.Keys - .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .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(); @@ -123,15 +176,26 @@ public sealed class InMemoryFileSystem : IFileSystem 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, StringComparison.OrdinalIgnoreCase)) + .Where(key => key.StartsWith(prefix, _comparison)) .Where(key => !key[prefix.Length..].Contains('/')) .OrderBy(key => key, StringComparer.OrdinalIgnoreCase) .ToList(); } - public DateTime GetLastWriteTimeUtc(string path) => LastWriteTimeUtc; + 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; @@ -145,10 +209,56 @@ public sealed class InMemoryFileSystem : IFileSystem return this; } - private byte[] Bytes(string path) => - _files.TryGetValue(Normalize(path), out var bytes) + 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) { diff --git a/StarterNG.Tests/Fakes/TestLog.cs b/StarterNG.Tests/Fakes/TestLog.cs new file mode 100644 index 0000000..7ea6fcc --- /dev/null +++ b/StarterNG.Tests/Fakes/TestLog.cs @@ -0,0 +1,12 @@ +using StarterNG.Application.Abstractions; +using StarterNG.Infrastructure.Adapters; + +namespace StarterNG.Tests.Fakes; + +/// The diagnostics log for a test that only wants one to write to or read back. +public static class TestLog +{ + /// Where the log file goes; a disk of its own when left out. + public static FileDiagnosticsLog Over(IFileSystem? files = null) => + new(files ?? new InMemoryFileSystem(), new FixedClock(new DateTime(2026, 10, 13)), new GamePaths("game")); +} diff --git a/StarterNG.Tests/FavoriteSceneriesTests.cs b/StarterNG.Tests/FavoriteSceneriesTests.cs index 098fb8c..244b5dd 100644 --- a/StarterNG.Tests/FavoriteSceneriesTests.cs +++ b/StarterNG.Tests/FavoriteSceneriesTests.cs @@ -1,5 +1,4 @@ using StarterNG.Application; -using StarterNG.Domain.Sceneries; using StarterNG.Tests.Fakes; namespace StarterNG.Tests; @@ -35,4 +34,43 @@ public class FavoriteSceneriesTests Assert.False(favorites.Contains(scenery)); } + + [Fact] + public void A_file_that_could_not_be_read_goes_aside_before_a_star_replaces_it() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithLegacyFile(TestInstallation.At("scenery", "td.scn"), "//$n Testowa") + .WithFile(FavoritesPath, "one.scn\ntwo.scn") + .FailReading(FavoritesPath)); + var scenery = Assert.Single(installation.Sceneries.LoadAll()); + var favorites = new FavoriteSceneries(installation.Files, installation.Log, FavoritesPath); + + installation.Files.AllowReading(FavoritesPath); + favorites.Toggle(scenery); + + Assert.Equal("one.scn\ntwo.scn", installation.Files.ReadAllText(FavoritesPath + ".unreadable")); + Assert.Equal("td.scn", installation.Files.ReadAllText(FavoritesPath)); + Assert.Contains(FavoritesPath + ".unreadable", installation.Log.Text); + + // Set aside once: the file is the starter's own again. + favorites.Toggle(scenery); + Assert.False(installation.Files.FileExists(FavoritesPath + ".unreadable-2")); + } + + [Fact] + public void A_locked_file_is_left_alone_when_no_copy_of_it_can_be_made() + { + var installation = new TestInstallation(new InMemoryFileSystem() + .WithLegacyFile(TestInstallation.At("scenery", "td.scn"), "//$n Testowa") + .WithFile(FavoritesPath, "one.scn\ntwo.scn") + .FailReading(FavoritesPath)); + var scenery = Assert.Single(installation.Sceneries.LoadAll()); + var favorites = new FavoriteSceneries(installation.Files, installation.Log, FavoritesPath); + + favorites.Toggle(scenery); + + installation.Files.AllowReading(FavoritesPath); + Assert.Equal("one.scn\ntwo.scn", installation.Files.ReadAllText(FavoritesPath)); + Assert.Contains("writing", installation.Log.Text); + } } diff --git a/StarterNG.Tests/FileDiagnosticsLogTests.cs b/StarterNG.Tests/FileDiagnosticsLogTests.cs index aa3f629..e918a77 100644 --- a/StarterNG.Tests/FileDiagnosticsLogTests.cs +++ b/StarterNG.Tests/FileDiagnosticsLogTests.cs @@ -42,4 +42,128 @@ public class FileDiagnosticsLogTests Assert.Equal(string.Empty, log.Text); } + + private static string Written(string path) + { + // The writer holds it open for as long as the session lasts. + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + [Fact] + public void The_first_entry_of_a_session_keeps_the_log_of_the_one_before() + { + string root = Directory.CreateTempSubdirectory("starterng-log-").FullName; + try + { + var paths = new GamePaths(root); + Directory.CreateDirectory(paths.StarterConfig); + File.WriteAllText(paths.DiagnosticsLog, "the crash\n"); + var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths); + + log.Log("after the restart"); + log.Log("and on"); + + Assert.Equal("the crash\n", Written(paths.DiagnosticsLog + ".1")); + string now = Written(paths.DiagnosticsLog); + Assert.DoesNotContain("the crash", now); + Assert.Contains("after the restart", now); + Assert.Contains("and on", now); + log.Clear(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Only_the_session_before_is_kept() + { + string root = Directory.CreateTempSubdirectory("starterng-log-").FullName; + try + { + var paths = new GamePaths(root); + Directory.CreateDirectory(paths.StarterConfig); + File.WriteAllText(paths.DiagnosticsLog, "the last one\n"); + File.WriteAllText(paths.DiagnosticsLog + ".1", "the one before it\n"); + var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths); + + log.Log("now"); + + Assert.Equal("the last one\n", Written(paths.DiagnosticsLog + ".1")); + log.Clear(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Clearing_the_log_does_not_touch_the_session_kept() + { + var log = Build(out var files); + string path = new GamePaths("game").DiagnosticsLog; + files.WithFile(path, "the crash\n"); + + log.Log("after the restart"); + log.Clear(); + log.Log("cleared"); + + Assert.Equal("the crash\n", files.ReadAllText(path + ".1")); + } + + [Fact] + public void A_log_that_cannot_be_copied_is_added_to_and_not_wiped() + { + string root = Directory.CreateTempSubdirectory("starterng-log-").FullName; + try + { + var paths = new GamePaths(root); + Directory.CreateDirectory(paths.StarterConfig); + File.WriteAllText(paths.DiagnosticsLog, "the crash\n"); + // A folder where the copy would go: nothing can be written in its place. + Directory.CreateDirectory(paths.DiagnosticsLog + ".1"); + var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths); + + log.Log("after the restart"); + + string now = Written(paths.DiagnosticsLog); + Assert.Contains("the crash", now); + Assert.Contains("after the restart", now); + log.Clear(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void A_folder_it_cannot_write_in_does_not_stop_the_program() + { + string root = Directory.CreateTempSubdirectory("starterng-log-").FullName; + try + { + var paths = new GamePaths(root); + // A file where the log's folder should be, as unwritable as read-only gets + // on every platform and for every user. + File.WriteAllText(paths.StarterConfig, ""); + var log = new FileDiagnosticsLog(new PhysicalFileSystem(), new FixedClock(new DateTime(2026, 3, 7)), paths); + + log.Log("first"); + log.Log("second"); + log.Flush(); + log.Clear(); + log.Log("third"); + + Assert.Contains("third", log.Text); + } + finally + { + Directory.Delete(root, recursive: true); + } + } } diff --git a/StarterNG.Tests/PhysicalFileSystemTests.cs b/StarterNG.Tests/PhysicalFileSystemTests.cs new file mode 100644 index 0000000..1d0e85c --- /dev/null +++ b/StarterNG.Tests/PhysicalFileSystemTests.cs @@ -0,0 +1,36 @@ +using StarterNG.Infrastructure.Adapters; + +namespace StarterNG.Tests; + +public sealed class PhysicalFileSystemTests : IDisposable +{ + private readonly string _root = Directory.CreateTempSubdirectory("starterng-disk-").FullName; + private readonly PhysicalFileSystem _files = new(); + + public void Dispose() => Directory.Delete(_root, recursive: true); + + [Fact] + public void An_atomic_write_makes_or_replaces_the_file_and_leaves_nothing_beside_it() + { + string path = Path.Combine(_root, "file.json"); + + _files.WriteAllTextAtomic(path, "first"); + _files.WriteAllTextAtomic(path, "zażółć"); + + Assert.Equal("zażółć", File.ReadAllText(path)); + Assert.Equal(new[] { path }, Directory.GetFiles(_root)); + } + + [Fact] + public void An_atomic_write_that_cannot_finish_leaves_what_stood_there() + { + string path = Path.Combine(_root, "file.json"); + File.WriteAllText(path, "what stood there"); + // Nowhere to put the new text on its way in. + Directory.CreateDirectory(path + PhysicalFileSystem.TempSuffix); + + Assert.ThrowsAny(() => _files.WriteAllTextAtomic(path, "half")); + + Assert.Equal("what stood there", File.ReadAllText(path)); + } +} diff --git a/StarterNG.Tests/PresetFileTests.cs b/StarterNG.Tests/PresetFileTests.cs new file mode 100644 index 0000000..7886e7c --- /dev/null +++ b/StarterNG.Tests/PresetFileTests.cs @@ -0,0 +1,160 @@ +using StarterNG.Classes; +using StarterNG.Infrastructure.Adapters; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +// Over the fake disk throughout: PresetStore itself is the player's real file. +public class PresetFileTests +{ + private const string PresetsPath = "/home/user/.config/MaSzyna/starter/userpresets.json"; + + private const string ThreePresets = """ + { "Presets": [ + { "Name": "a", "Entry": "trainset a" }, + { "Name": "b", "Entry": "trainset b" }, + { "Name": "c", "Entry": "trainset c" } + ] } + """; + + private static (PresetFile Presets, InMemoryFileSystem Files, FileDiagnosticsLog Log) Rig(string? held = null) + { + var files = new InMemoryFileSystem(); + if (held is not null) + files.WithFile(PresetsPath, held); + var log = TestLog.Over(files); + return (new PresetFile(files, log, PresetsPath), files, log); + } + + [Fact] + public void A_saved_preset_joins_the_ones_in_the_file() + { + var (presets, files, _) = Rig(ThreePresets); + + presets.Save("d", "trainset d"); + + Assert.Equal(new[] { "a", "b", "c", "d" }, presets.Presets().Select(p => p.Name)); + Assert.False(files.FileExists(PresetsPath + ".unreadable")); + } + + [Fact] + public void Saving_under_a_name_there_replaces_that_one() + { + var (presets, _, _) = Rig(ThreePresets); + + presets.Save("B", "trainset other"); + + Assert.Equal(new[] { "a", "B", "c" }, presets.Presets().Select(p => p.Name)); + } + + [Fact] + public void A_first_preset_needs_no_file_to_be_there() + { + var (presets, files, log) = Rig(); + + presets.Save("a", "trainset a"); + + Assert.Equal("a", Assert.Single(presets.Presets()).Name); + Assert.False(files.FileExists(PresetsPath + ".unreadable")); + Assert.Equal(string.Empty, log.Text); + } + + [Theory] + [InlineData("""{ "Presets": [ { "Name": "a", "Entry": "trainset a" }, { "Na""")] + [InlineData("")] + [InlineData("null")] + [InlineData("""{ "Presets": null }""")] + public void A_file_cut_short_goes_aside_before_a_save_replaces_it(string held) + { + var (presets, files, log) = Rig(held); + + presets.Save("new", "trainset new"); + + Assert.Equal(held, files.ReadAllText(PresetsPath + ".unreadable")); + Assert.Equal("new", Assert.Single(presets.Presets()).Name); + Assert.Contains(PresetsPath + ".unreadable", log.Text); + } + + [Fact] + public void An_earlier_copy_set_aside_is_not_overwritten() + { + var (presets, files, _) = Rig("{ cut"); + files.WithFile(PresetsPath + ".unreadable", "older"); + + presets.Save("new", "trainset new"); + + Assert.Equal("older", files.ReadAllText(PresetsPath + ".unreadable")); + Assert.Equal("{ cut", files.ReadAllText(PresetsPath + ".unreadable-2")); + } + + [Fact] + public void A_locked_file_is_left_alone_when_no_copy_of_it_can_be_made() + { + var (presets, files, log) = Rig(ThreePresets); + files.FailReading(PresetsPath); + + presets.Save("new", "trainset new"); + + files.AllowReading(PresetsPath); + Assert.Equal(ThreePresets, files.ReadAllText(PresetsPath)); + Assert.False(files.FileExists(PresetsPath + ".unreadable")); + Assert.Contains("saving", log.Text); + } + + [Fact] + public void A_file_unlocked_between_the_read_and_the_write_is_still_kept() + { + // The list in hand is the empty one from the read that failed: the file has + // to be kept whatever it looks like by the time it would be replaced. + var (presets, files, _) = Rig(ThreePresets); + var unlocking = new UnlockingLog(files, PresetsPath); + var racing = new PresetFile(files, unlocking, PresetsPath); + files.FailReading(PresetsPath); + + racing.Save("new", "trainset new"); + + Assert.Equal(ThreePresets, files.ReadAllText(PresetsPath + ".unreadable")); + Assert.Equal("new", Assert.Single(presets.Presets()).Name); + } + + [Fact] + public void Deleting_from_an_unreadable_file_leaves_it_as_it_is() + { + var (presets, files, _) = Rig("{ cut"); + + presets.Delete("a"); + + Assert.Equal("{ cut", files.ReadAllText(PresetsPath)); + Assert.False(files.FileExists(PresetsPath + ".unreadable")); + } + + [Fact] + public void An_unreadable_file_shows_as_no_presets_and_is_logged() + { + var (presets, _, log) = Rig("{ cut"); + + Assert.Empty(presets.Presets()); + Assert.Contains("reading", log.Text); + } + + /// Lets the file go the moment its read has failed and been logged. + private sealed class UnlockingLog : StarterNG.Application.Abstractions.IDiagnosticsLog + { + private readonly InMemoryFileSystem _files; + private readonly string _path; + + public UnlockingLog(InMemoryFileSystem files, string path) + { + _files = files; + _path = path; + } + + public string LogPath => ""; + public string Text => ""; + public void Log(string message) => _files.AllowReading(_path); + public void Log(string context, Exception exception) => _files.AllowReading(_path); + public void Log(IEnumerable messages) => _files.AllowReading(_path); + public void Clear() { } + public void Flush() { } + } +} diff --git a/StarterNG/Application/Abstractions/IFileSystem.cs b/StarterNG/Application/Abstractions/IFileSystem.cs index b30f657..66e8ca3 100644 --- a/StarterNG/Application/Abstractions/IFileSystem.cs +++ b/StarterNG/Application/Abstractions/IFileSystem.cs @@ -25,6 +25,16 @@ public interface IFileSystem void WriteAllText(string path, string contents, Encoding encoding); + /// + /// Writes the file whole or not at all: a crash or a full disk half way leaves + /// what stood there, not the start of what was on its way. A link stays a link, + /// its file gets the contents; a file keeps its mode. + /// + void WriteAllBytesAtomic(string path, byte[] bytes); + + /// As UTF-8 without a mark, by . + void WriteAllTextAtomic(string path, string contents); + void WriteAllLines(string path, IEnumerable lines); void WriteAllBytes(string path, byte[] bytes); @@ -46,4 +56,32 @@ public interface IFileSystem long GetFileSize(string path); bool IsExecutable(string path); + + /// Where the symbolic link at points, or null for anything else. + string? LinkTarget(string path); + + /// Makes a symbolic link to the directory . + void CreateDirectoryLink(string path, string target); + + void MoveDirectory(string sourcePath, string destinationPath); +} + +public static class FileSystemExtensions +{ + /// + /// Copies a file that could not be read beside itself, before something replaces + /// it: it may hold what was not read. Throws when it cannot; then it must stay. + /// + public static void KeepAside(this IFileSystem files, string path, IDiagnosticsLog log) + { + if (!files.FileExists(path)) + return; + + string aside = path + ".unreadable"; + for (int n = 2; files.FileExists(aside); n++) + aside = $"{path}.unreadable-{n}"; + + files.CopyFile(path, aside, overwrite: false); + log.Log($"{path} could not be read; a copy is kept as {aside}"); + } } diff --git a/StarterNG/Application/FavoriteSceneries.cs b/StarterNG/Application/FavoriteSceneries.cs index 4a00831..1180953 100644 --- a/StarterNG/Application/FavoriteSceneries.cs +++ b/StarterNG/Application/FavoriteSceneries.cs @@ -18,6 +18,9 @@ public sealed class FavoriteSceneries private readonly string _path; private readonly HashSet _names = new(StringComparer.OrdinalIgnoreCase); + // The file holds more than _names does: it goes aside before anything replaces it. + private bool _unread; + public FavoriteSceneries(IFileSystem files, IDiagnosticsLog log, string path) { _files = files; @@ -52,6 +55,7 @@ public sealed class FavoriteSceneries catch (Exception ex) { _log.Log($"reading {_path}", ex); + _unread = true; } } @@ -62,7 +66,12 @@ public sealed class FavoriteSceneries if (Path.GetDirectoryName(_path) is { Length: > 0 } directory) _files.CreateDirectory(directory); - _files.WriteAllText(_path, string.Join('\n', _names.Order(StringComparer.OrdinalIgnoreCase))); + if (_unread) + { + _files.KeepAside(_path, _log); + _unread = false; + } + _files.WriteAllTextAtomic(_path, string.Join('\n', _names.Order(StringComparer.OrdinalIgnoreCase))); } catch (Exception ex) { diff --git a/StarterNG/Classes/ConfigFile.cs b/StarterNG/Classes/ConfigFile.cs index e551b2c..63be091 100644 --- a/StarterNG/Classes/ConfigFile.cs +++ b/StarterNG/Classes/ConfigFile.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using System.Text; namespace StarterNG.Classes; @@ -12,6 +13,12 @@ public sealed class ConfigFile private readonly Dictionary _index = new(StringComparer.OrdinalIgnoreCase); + // The simulator stops reading at this word (Globals.cpp), whatever its case. + private const string EndKey = "endconfig"; + + // Its first line, or -1 when the file runs to its end. + private int _end = -1; + public static ConfigFile Parse(string text) { var cfg = new ConfigFile(); @@ -24,14 +31,44 @@ public sealed class ConfigFile private void AddLine(string raw) { - int idx = _lines.Count; _lines.Add(raw); - if (TryParseKey(raw, out string key)) - _index[key] = idx; + IndexLine(_lines.Count - 1); + } + + private void IndexLine(int line) + { + if (!TryParseKey(_lines[line], out string key)) + return; + + if (_end < 0 && key.Equals(EndKey, StringComparison.OrdinalIgnoreCase)) + _end = line; + + // Of a key on both sides of the end, the line the simulator reads counts. + if (_end < 0 || line == _end || !_index.TryGetValue(key, out int seen) || seen > _end) + _index[key] = line; + } + + private void Reindex() + { + _index.Clear(); + _end = -1; + for (int line = 0; line < _lines.Count; line++) + IndexLine(line); } public bool Has(string key) => _index.ContainsKey(key); + /// Whether the line setting the key is one the simulator never gets to. + public bool IsPastEnd(string key) => + _end >= 0 && _index.TryGetValue(key, out int line) && line > _end; + + /// Every key the file sets, in the order of its lines, with its value. + public IEnumerable> Entries() + { + foreach (var (key, _) in _index.OrderBy(pair => pair.Value)) + yield return new KeyValuePair(key, GetRaw(key) ?? string.Empty); + } + public string? GetRaw(string key) { if (!_index.TryGetValue(key, out int i)) @@ -83,35 +120,61 @@ public sealed class ConfigFile if (_index.TryGetValue(key, out int i)) { SplitLine(_lines[i], out string indent, out _, out _, out string comment); - var sb = new StringBuilder(); - sb.Append(indent).Append(key); - if (value.Length > 0) - sb.Append(' ').Append(value); - if (comment.Length > 0) - sb.Append('\t').Append(comment); - _lines[i] = sb.ToString(); + string line = Line(indent, key, value, comment); + + if (i > _end && _end >= 0) + { + _lines.RemoveAt(i); + InsertBeforeEnd(line); + } + else + { + _lines[i] = line; + } + } + else if (_end >= 0) + { + InsertBeforeEnd(Line("", key, value, "")); } else { int idx = _lines.Count; - _lines.Add(value.Length > 0 ? $"{key} {value}" : key); + _lines.Add(Line("", key, value, "")); _index[key] = idx; } } - public void Remove(string key) + private static string Line(string indent, string key, string value, string comment) { - if (!_index.TryGetValue(key, out int i)) - return; - - SplitLine(_lines[i], out string indent, out _, out string value, out string comment); var sb = new StringBuilder(); - sb.Append(indent).Append("// ").Append(key); + sb.Append(indent).Append(key); if (value.Length > 0) sb.Append(' ').Append(value); if (comment.Length > 0) sb.Append('\t').Append(comment); - _lines[i] = sb.ToString(); + return sb.ToString(); + } + + private void InsertBeforeEnd(string line) + { + _lines.Insert(_end, line); + Reindex(); + } + + /// Comments out every line setting the key: the last one alone would bring the one before it back. + public void Remove(string key) + { + if (!_index.ContainsKey(key)) + return; + + for (int i = 0; i < _lines.Count; i++) + { + SplitLine(_lines[i], out string indent, out string found, out string value, out string comment); + if (!found.Equals(key, StringComparison.OrdinalIgnoreCase)) + continue; + + _lines[i] = Line(indent + "// ", found, value, comment); + } _index.Remove(key); } @@ -133,7 +196,7 @@ public sealed class ConfigFile private static void SplitLine(string raw, out string indent, out string key, out string value, out string comment) { - indent = key = value = comment = string.Empty; + key = value = comment = string.Empty; int start = 0; while (start < raw.Length && char.IsWhiteSpace(raw[start])) diff --git a/StarterNG/Classes/PresetStore.cs b/StarterNG/Classes/PresetStore.cs index b03a748..69b7e82 100644 --- a/StarterNG/Classes/PresetStore.cs +++ b/StarterNG/Classes/PresetStore.cs @@ -1,13 +1,13 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; -using System.Linq; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using System.Text.RegularExpressions; +using StarterNG.Application; +using StarterNG.Application.Abstractions; using StarterNG.Infrastructure.Adapters; namespace StarterNG.Classes; @@ -54,18 +54,14 @@ public static class PresetStore public static PresetSort SortMode { get; set; } = PresetSort.Name; - private static readonly JsonSerializerOptions Options = new() - { - WriteIndented = true, - TypeInfoResolver = PresetJsonContext.Default, - }; + private static PresetFile? _disk; - private static JsonTypeInfo TypeInfo => - (JsonTypeInfo)Options.GetTypeInfo(typeof(PresetCollection)); + private static PresetFile Disk => + _disk ??= new PresetFile(AppServices.Current.Files, AppServices.Current.Log, FilePath); public static IReadOnlyList All() { - var list = Load().Presets.ToList(); + var list = Disk.Presets(); if (SortMode == PresetSort.Track) list.Sort((a, b) => string.Compare(TrackOf(a), TrackOf(b), StringComparison.OrdinalIgnoreCase)); else @@ -73,25 +69,9 @@ public static class PresetStore return list; } - public static void Save(string? name, string? entry) - { - name = name?.Trim() ?? ""; - if (name.Length == 0 || string.IsNullOrWhiteSpace(entry)) - return; + public static void Save(string? name, string? entry) => Disk.Save(name, entry); - var col = Load(); - col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); - col.Presets.Add(new TrainsetPreset { Name = name, Entry = entry! }); - col.Presets.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); - Persist(col); - } - - public static void Delete(string name) - { - var col = Load(); - if (col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) > 0) - Persist(col); - } + public static void Delete(string name) => Disk.Delete(name); public static int ImportMagazyn(string? path = null) { @@ -162,35 +142,95 @@ public static class PresetStore private static string Sanitize(string name) => string.IsNullOrWhiteSpace(name) ? "preset" : name.Replace(' ', '_'); +} - private static PresetCollection Load() +/// The presets as one file holds them; is the player's own. +public sealed class PresetFile +{ + private static readonly JsonSerializerOptions Options = new() { - try - { - if (File.Exists(FilePath)) - { - var col = JsonSerializer.Deserialize(File.ReadAllText(FilePath), TypeInfo); - if (col?.Presets != null) - return col; - } - } - catch (Exception ex) - { - StarterNG.Infrastructure.Diagnostics.Log($"reading {FilePath}", ex); - } - return new PresetCollection(); + WriteIndented = true, + TypeInfoResolver = PresetJsonContext.Default, + }; + + private static JsonTypeInfo TypeInfo => + (JsonTypeInfo)Options.GetTypeInfo(typeof(PresetCollection)); + + private readonly IFileSystem _files; + private readonly IDiagnosticsLog _log; + private readonly string _path; + + public PresetFile(IFileSystem files, IDiagnosticsLog log, string path) + { + _files = files; + _log = log; + _path = path; } - private static void Persist(PresetCollection col) + public List Presets() { + TryRead(out var col); + return col.Presets; + } + + public void Save(string? name, string? entry) + { + name = name?.Trim() ?? ""; + if (name.Length == 0 || string.IsNullOrWhiteSpace(entry)) + return; + + bool read = TryRead(out var col); + col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); + col.Presets.Add(new TrainsetPreset { Name = name, Entry = entry! }); + col.Presets.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); + Persist(col, read); + } + + public void Delete(string name) + { + bool read = TryRead(out var col); + if (col.Presets.RemoveAll(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) > 0) + Persist(col, read); + } + + /// The file's presets; false, with none, when there is a file but they cannot be had from it. + private bool TryRead(out PresetCollection col) + { + col = new PresetCollection(); try { - Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); - File.WriteAllText(FilePath, JsonSerializer.Serialize(col, TypeInfo)); + if (!_files.FileExists(_path)) + return true; + + var read = JsonSerializer.Deserialize(_files.ReadAllText(_path), TypeInfo); + if (read?.Presets != null) + { + col = read; + return true; + } + _log.Log($"reading {_path}: no presets in it"); } catch (Exception ex) { - StarterNG.Infrastructure.Diagnostics.Log($"saving {FilePath}", ex); + _log.Log($"reading {_path}", ex); + } + return false; + } + + // What was not read is not in the list: written over, those presets are gone, so + // a copy goes first and without one the file stays - the new preset waits. + private void Persist(PresetCollection col, bool read) + { + try + { + _files.CreateDirectory(Path.GetDirectoryName(_path)!); + if (!read) + _files.KeepAside(_path, _log); + _files.WriteAllTextAtomic(_path, JsonSerializer.Serialize(col, TypeInfo)); + } + catch (Exception ex) + { + _log.Log($"saving {_path}", ex); } } } diff --git a/StarterNG/Infrastructure/Adapters/FileDiagnosticsLog.cs b/StarterNG/Infrastructure/Adapters/FileDiagnosticsLog.cs index e1c8e5c..91e249c 100644 --- a/StarterNG/Infrastructure/Adapters/FileDiagnosticsLog.cs +++ b/StarterNG/Infrastructure/Adapters/FileDiagnosticsLog.cs @@ -17,6 +17,7 @@ public sealed class FileDiagnosticsLog : IDiagnosticsLog private StreamWriter? _writer; private bool _writerFailed; + private bool _previousKept; public FileDiagnosticsLog(IFileSystem files, IClock clock, IGamePaths paths) { @@ -91,7 +92,8 @@ public sealed class FileDiagnosticsLog : IDiagnosticsLog try { _files.CreateDirectory(Path.GetDirectoryName(_logPath)!); - _files.WriteAllLines(_logPath, _lines); + if (KeepPreviousSession()) + _files.WriteAllLines(_logPath, _lines); } catch { @@ -99,6 +101,26 @@ public sealed class FileDiagnosticsLog : IDiagnosticsLog } } + // What the last session left, a crash's traces above all, is what this log is + // read for: one copy of it goes aside before the first entry of this one. False + // when it could not, and the log is then added to instead of started over. + private bool KeepPreviousSession() + { + if (_previousKept) + return true; + + try + { + if (_files.FileExists(_logPath)) + _files.CopyFile(_logPath, _logPath + ".1", overwrite: true); + return _previousKept = true; + } + catch + { + return false; + } + } + private void Append(string line) { if (_writerFailed) @@ -109,7 +131,7 @@ public sealed class FileDiagnosticsLog : IDiagnosticsLog if (_writer is null) { _files.CreateDirectory(Path.GetDirectoryName(_logPath)!); - _writer = new StreamWriter(_logPath, append: false) { AutoFlush = true }; + _writer = new StreamWriter(_logPath, append: !KeepPreviousSession()) { AutoFlush = true }; } _writer.WriteLine(line); } diff --git a/StarterNG/Infrastructure/Adapters/GamePaths.cs b/StarterNG/Infrastructure/Adapters/GamePaths.cs index bbf86db..58f5741 100644 --- a/StarterNG/Infrastructure/Adapters/GamePaths.cs +++ b/StarterNG/Infrastructure/Adapters/GamePaths.cs @@ -10,7 +10,24 @@ public sealed class GamePaths : IGamePaths Root = root; } - public static GamePaths ForCurrentDirectory() => new(Directory.GetCurrentDirectory()); + /// + /// The working directory, which is how the game's own shortcuts point at it - or + /// the starter's folder where only that one holds a game, as when started from a + /// shortcut set up elsewhere. Moved there too: more than these paths go by it. + /// + public static GamePaths ForCurrentDirectory() + { + string current = Directory.GetCurrentDirectory(); + string own = System.AppContext.BaseDirectory; + if (HoldsGame(current) || !HoldsGame(own)) + return new GamePaths(current); + + Directory.SetCurrentDirectory(own); + return new GamePaths(Directory.GetCurrentDirectory()); + } + + private static bool HoldsGame(string folder) => + Directory.Exists(Path.Combine(folder, "scenery")) && Directory.Exists(Path.Combine(folder, "dynamic")); public string Root { get; } diff --git a/StarterNG/Infrastructure/Adapters/LegacyText.cs b/StarterNG/Infrastructure/Adapters/LegacyText.cs index 5eebd01..b7f5b85 100644 --- a/StarterNG/Infrastructure/Adapters/LegacyText.cs +++ b/StarterNG/Infrastructure/Adapters/LegacyText.cs @@ -32,14 +32,26 @@ public static class LegacyText /// public static string Decode(byte[] bytes) { - ReadOnlySpan text = bytes; - if (text.StartsWith(Utf8Bom)) - text = text[Utf8Bom.Length..]; - - return Utf8.IsValid(text) ? Encoding.UTF8.GetString(text) : CodePage1250.GetString(text); + var (encoding, bom) = Detect(bytes); + return encoding.GetString(bytes, bom.Length, bytes.Length - bom.Length); } - private static ReadOnlySpan Utf8Bom => [0xEF, 0xBB, 0xBF]; + /// + /// What a game file is written in, so it is written back the way it was read: + /// UTF-8 only for a file that is UTF-8 and says so with its mark or a letter + /// outside ASCII, code page 1250 - the game's own - else. That one gives every + /// byte back as it was, also behind a mark that lied; UTF-8 would turn what is + /// not UTF-8 into replacement characters. + /// + public static (Encoding Encoding, byte[] Bom) Detect(byte[] bytes) + { + byte[] bom = bytes.AsSpan().StartsWith(Utf8Bom) ? Utf8Bom : Array.Empty(); + var body = bytes.AsSpan(bom.Length); + bool utf8 = Utf8.IsValid(body) && (bom.Length > 0 || body.ContainsAnyInRange((byte)0x80, (byte)0xFF)); + return (utf8 ? new UTF8Encoding(false) : CodePage1250, bom); + } + + private static readonly byte[] Utf8Bom = { 0xEF, 0xBB, 0xBF }; private static Encoding Resolve() { diff --git a/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs b/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs index 69e0c0f..db9350d 100644 --- a/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs +++ b/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs @@ -8,6 +8,8 @@ namespace StarterNG.Infrastructure.Adapters; public sealed class PhysicalFileSystem : IFileSystem { + public const string TempSuffix = ".starter.tmp"; + public bool FileExists(string path) => File.Exists(path); public bool DirectoryExists(string path) => Directory.Exists(path); @@ -20,13 +22,61 @@ public sealed class PhysicalFileSystem : IFileSystem public byte[] ReadAllBytes(string path) => File.ReadAllBytes(path); - public Stream OpenRead(string path) => File.OpenRead(path); + // Shared for writing too, so a file the simulator still holds open - a physics log of + // a running trip - can be read. + public Stream OpenRead(string path) => + new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); public void WriteAllText(string path, string contents) => File.WriteAllText(path, contents); public void WriteAllText(string path, string contents, Encoding encoding) => File.WriteAllText(path, contents, encoding); + public void WriteAllTextAtomic(string path, string contents) => + WriteAllBytesAtomic(path, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(contents)); + + /// + /// Written beside the file and moved over it. A crash at any moment leaves the old + /// file or the new one whole - at worst with the temporary one beside it, which + /// the next save writes over, under a name nothing in the game reads. + /// + public void WriteAllBytesAtomic(string path, byte[] bytes) + { + // Through a link, to its file: moved over the link, the text would take its place. + if (File.Exists(path) && File.ResolveLinkTarget(path, returnFinalTarget: true) is { } linked) + path = linked.FullName; + + string temporary = path + TempSuffix; + try + { + using (var stream = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None)) + { + stream.Write(bytes); + // On the disk itself first, or a power cut could leave the new name with no text under it. + stream.Flush(flushToDisk: true); + } + + // A new file gets the default mode; a write in place kept the old one. + if (!OperatingSystem.IsWindows() && File.Exists(path) && + File.GetUnixFileMode(path) is var mode && mode != File.GetUnixFileMode(temporary)) + File.SetUnixFileMode(temporary, mode); + + File.Move(temporary, path, overwrite: true); + } + catch + { + try + { + File.Delete(temporary); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // The failure to tell is the one that brought us here. + } + throw; + } + } + public void WriteAllLines(string path, IEnumerable lines) => File.WriteAllLines(path, lines); public void WriteAllBytes(string path, byte[] bytes) => File.WriteAllBytes(path, bytes); @@ -41,9 +91,16 @@ public sealed class PhysicalFileSystem : IFileSystem public IReadOnlyList GetFiles(string path, string searchPattern = "*") => Directory.Exists(path) ? Directory.GetFiles(path, searchPattern) : Array.Empty(); + // A folder it may not enter costs what is in it, not the whole listing. public IReadOnlyList GetFilesRecursive(string path, string searchPattern) => Directory.Exists(path) - ? Directory.GetFiles(path, searchPattern, SearchOption.AllDirectories) + ? Directory.GetFiles(path, searchPattern, new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + AttributesToSkip = 0, + MatchType = MatchType.Win32 + }) : Array.Empty(); public IReadOnlyList GetDirectories(string path) => @@ -62,4 +119,11 @@ public sealed class PhysicalFileSystem : IFileSystem UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; return (File.GetUnixFileMode(path) & anyExecute) != 0; } + + public string? LinkTarget(string path) => new FileInfo(path).LinkTarget; + + public void CreateDirectoryLink(string path, string target) => Directory.CreateSymbolicLink(path, target); + + public void MoveDirectory(string sourcePath, string destinationPath) => + Directory.Move(sourcePath, destinationPath); } diff --git a/StarterNG/Infrastructure/Diagnostics.cs b/StarterNG/Infrastructure/Diagnostics.cs index 4430690..8395c7a 100644 --- a/StarterNG/Infrastructure/Diagnostics.cs +++ b/StarterNG/Infrastructure/Diagnostics.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.IO; using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -53,6 +55,25 @@ public static class Diagnostics Dispatcher.UIThread.Post(() => _ = ReportAsync(message, title)); } + /// Opens a file or folder the way the system does; a dialog says if it is not there or will not open. + public static void OpenOrReport(string path) + { + if (!File.Exists(path) && !Directory.Exists(path)) + { + ReportOnUiThread(string.Format(App.Loc["FaultFileNotFound"], path)); + return; + } + + try + { + Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); + } + catch (Exception ex) + { + _ = ReportAsync(path, ex); + } + } + public static InstallationCheck InstallationCheck() => new(AppServices.Current.Paths, AppServices.Current.Files,