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,