Skip to content

Commit 9646e10

Browse files
authored
Handle FileNotFoundException when generating the CoA extraction mod (#2944) #patch
Sentry event ID: 75489e34df5b47c896a9a5416fe41843
1 parent bff8d3f commit 9646e10

3 files changed

Lines changed: 147 additions & 13 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
using System;
2+
using System.IO;
3+
using Xunit;
4+
using ImperatorToCK3.CommonUtils;
5+
using commonItems.Mods;
6+
using ImperatorToCK3.Imperator;
7+
using commonItems.Exceptions;
8+
9+
namespace ImperatorToCK3.UnitTests.CommonUtils {
10+
public class FileHelperTests : IDisposable {
11+
private readonly string tempRoot;
12+
13+
public FileHelperTests() {
14+
tempRoot = Path.Combine(Path.GetTempPath(), "IRToCK3Tests", Guid.NewGuid().ToString());
15+
Directory.CreateDirectory(tempRoot);
16+
}
17+
18+
public void Dispose() {
19+
try {
20+
Directory.Delete(tempRoot, recursive: true);
21+
} catch { /* best effort cleanup */ }
22+
}
23+
24+
[Fact]
25+
public void EnsureDirectoryExists_createsMissingPath() {
26+
var target = Path.Combine(tempRoot, "a", "b", "c");
27+
Assert.False(Directory.Exists(target));
28+
29+
FileHelper.EnsureDirectoryExists(target);
30+
31+
Assert.True(Directory.Exists(target));
32+
}
33+
34+
[Fact]
35+
public void EnsureDirectoryExists_throwsWhenFileCollision() {
36+
var target = Path.Combine(tempRoot, "collisionDir");
37+
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
38+
File.WriteAllText(target, "oops");
39+
Assert.True(File.Exists(target));
40+
41+
var ex = Assert.Throws<UserErrorException>(() => FileHelper.EnsureDirectoryExists(target));
42+
Assert.Contains("directory", ex.Message, StringComparison.OrdinalIgnoreCase);
43+
44+
// original file must remain untouched
45+
Assert.True(File.Exists(target));
46+
Assert.False(Directory.Exists(target));
47+
}
48+
49+
[Fact]
50+
public void OutputGuiContainer_handlesFileInPlaceOfGuiDirectory() {
51+
// prepare a fake Imperator installation with one GUI file
52+
var gameRoot = Path.Combine(tempRoot, "game");
53+
var guiDir = Path.Combine(gameRoot, "gui");
54+
Directory.CreateDirectory(guiDir);
55+
var topbar = Path.Combine(guiDir, "ingame_topbar.gui");
56+
File.WriteAllText(topbar, "foo");
57+
58+
var modFS = new ModFilesystem(gameRoot, []);
59+
60+
// configuration points to a separate doc path; create a collision file
61+
var docPath = Path.Combine(tempRoot, "docs");
62+
var config = new Configuration {
63+
ImperatorDocPath = docPath
64+
};
65+
Directory.CreateDirectory(docPath);
66+
67+
var collisionFile = Path.Combine(docPath, "mod", "coa_export_mod", "gui");
68+
Directory.CreateDirectory(Path.GetDirectoryName(collisionFile)!);
69+
File.WriteAllText(collisionFile, "not a directory");
70+
71+
// run the helper; it should not crash but will bail out early
72+
World.OutputGuiContainer(modFS, [], config);
73+
74+
// collision file remains and no directory has been created
75+
var expectedGuiDir = Path.Combine(docPath, "mod", "coa_export_mod", "gui");
76+
Assert.False(Directory.Exists(expectedGuiDir));
77+
Assert.True(File.Exists(collisionFile));
78+
}
79+
}
80+
}

ImperatorToCK3/CommonUtils/FileHelper.cs

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
using commonItems.Exceptions;
1+

22

33
namespace ImperatorToCK3.CommonUtils;
44

55
using commonItems;
6+
using commonItems.Exceptions;
67
using System;
78
using Polly;
89
using System.IO;
@@ -69,7 +70,48 @@ public static void DeleteWithRetries(string filePath) {
6970
throw new UserErrorException($"Failed to delete \"{filePath}\". {CloseProgramsHint}");
7071
}
7172
}
72-
73+
74+
// Ensures that the given directory path exists. If a file exists with the
75+
// same name as the desired directory it will be removed first. The method
76+
// retries the creation when a sharing violation occurs, much like the
77+
// other helpers in this class. This helps mitigate cases where a transient
78+
// lock or a stray file prevents folder creation.
79+
public static void EnsureDirectoryExists(string directoryPath) {
80+
if (string.IsNullOrEmpty(directoryPath)) {
81+
return;
82+
}
83+
84+
// if the path already exists as a directory we're done
85+
if (Directory.Exists(directoryPath)) {
86+
return;
87+
}
88+
89+
// if a file exists where we'd like a directory, bail out rather than
90+
// attempting to delete it.
91+
if (File.Exists(directoryPath)) {
92+
throw new UserErrorException(
93+
$"Cannot create directory \"{directoryPath}\" because a file with the same name already exists.");
94+
}
95+
96+
const int maxAttempts = 10;
97+
int currentAttempt = 0;
98+
var policy = Policy
99+
.Handle<IOException>(IsFilesSharingViolation)
100+
.WaitAndRetry(maxAttempts,
101+
sleepDurationProvider: _ => TimeSpan.FromSeconds(1),
102+
onRetry: (_, _, _) => {
103+
currentAttempt++;
104+
Logger.Warn($"Attempt {currentAttempt} to create directory \"{directoryPath}\" failed.");
105+
});
106+
107+
try {
108+
policy.Execute(() => Directory.CreateDirectory(directoryPath));
109+
} catch (IOException ex) when (IsFilesSharingViolation(ex)) {
110+
Logger.Debug(ex.ToString());
111+
throw new UserErrorException($"Failed to create directory \"{directoryPath}\". {CloseProgramsHint}");
112+
}
113+
}
114+
73115
public static void MoveWithRetries(string sourceFilePath, string destFilePath) {
74116
const int maxAttempts = 10;
75117

ImperatorToCK3/Imperator/World.cs

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -91,39 +91,51 @@ protected World(Configuration config) {
9191

9292
diplomacyDB = new();
9393
}
94-
95-
private static void OutputGuiContainer(ModFilesystem modFS, IEnumerable<string> tagsNeedingFlags, Configuration config) {
94+
95+
internal static void OutputGuiContainer(ModFilesystem modFS, IEnumerable<string> tagsNeedingFlags, Configuration config) {
9696
Logger.Debug("Modifying gui for exporting CoAs...");
97-
97+
9898
const string relativeTopBarGuiPath = "gui/ingame_topbar.gui";
9999
var topBarGuiPath = modFS.GetActualFileLocation(relativeTopBarGuiPath);
100100
if (topBarGuiPath is null) {
101101
Logger.Warn($"{relativeTopBarGuiPath} not found, can't write CoA export commands!");
102102
return;
103103
}
104104

105+
// build the GUI snippet we want to insert
105106
var guiTextBuilder = new StringBuilder();
106107
guiTextBuilder.AppendLine("\tstate = {");
107108
guiTextBuilder.AppendLine("\t\tname = _show");
108109
string commandsString = string.Join(';', tagsNeedingFlags.Select(tag => $"coat_of_arms {tag}"));
109110
commandsString += ";dumpdatatypes"; // This will let us know when the commands finished executing.
110111
guiTextBuilder.AppendLine($"\t\ton_start=\"[ExecuteConsoleCommandsForced('{commandsString}')]\"");
111112
guiTextBuilder.AppendLine("\t}");
112-
113+
113114
List<string> lines = [.. File.ReadAllLines(topBarGuiPath)];
114115
int index = lines.FindIndex(line => line.Contains("name = \"ingame_topbar\""));
115116
if (index != -1) {
116117
lines.Insert(index + 1, guiTextBuilder.ToString());
117118
}
118119

119-
var topBarOutputPath = Path.Combine(config.ImperatorDocPath, "mod/coa_export_mod", relativeTopBarGuiPath);
120-
Logger.Debug($"Writing modified GUI to \"{topBarOutputPath}\"...");
121-
var topBarOutputDir = Path.GetDirectoryName(topBarOutputPath);
122-
if (topBarOutputDir is not null) {
123-
Directory.CreateDirectory(topBarOutputDir);
120+
// attempt to write the modified GUI
121+
try {
122+
var topBarOutputPath = Path.Combine(config.ImperatorDocPath, "mod/coa_export_mod", relativeTopBarGuiPath);
123+
Logger.Debug($"Writing modified GUI to \"{topBarOutputPath}\"...");
124+
var topBarOutputDir = Path.GetDirectoryName(topBarOutputPath);
125+
if (topBarOutputDir is not null) {
126+
FileHelper.EnsureDirectoryExists(topBarOutputDir);
127+
}
128+
129+
using var writer = FileHelper.OpenWriteWithRetries(topBarOutputPath, Encoding.UTF8);
130+
foreach (var line in lines) {
131+
writer.WriteLine(line);
132+
}
133+
} catch (Exception e) {
134+
Logger.Warn($"Failed to output modified GUI: {e.Message}");
135+
// bail out but don't crash the whole conversion
136+
return;
124137
}
125-
File.WriteAllLines(topBarOutputPath, lines);
126-
138+
127139
// Create a .mod file for the temporary mod.
128140
Logger.Debug("Creating temporary mod file...");
129141
string modFileContents =

0 commit comments

Comments
 (0)