Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 28 additions & 12 deletions CLAUDE.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace WitcherScriptMerger
{
class AppSettings
public class AppSettings
{
string _assemblyPath;

Expand All @@ -27,7 +27,7 @@ public AppSettings()

if (!CachedConfig.HasFile)
{
Program.Notifier.ShowError("Config file is missing.", "Script Merger Error");
AppState.Notifier.ShowError("Config file is missing.", "Script Merger Error");
Environment.Exit(1);
}
}
Expand Down Expand Up @@ -56,7 +56,7 @@ public T Get<T>(string key)
return (T)valueObject;
}

Program.Notifier.ShowError($"Config file doesn't exist:\n\n{CachedConfig.FilePath}");
AppState.Notifier.ShowError($"Config file doesn't exist:\n\n{CachedConfig.FilePath}");
return default(T);
}
catch
Expand All @@ -72,7 +72,7 @@ public string Get(string key)
if (CachedConfig.HasFile)
return CachedConfig.AppSettings.Settings[key].Value;

Program.Notifier.ShowError($"Config file doesn't exist:\n\n{CachedConfig.FilePath}");
AppState.Notifier.ShowError($"Config file doesn't exist:\n\n{CachedConfig.FilePath}");
return string.Empty;
}
catch
Expand All @@ -89,7 +89,7 @@ public void Save()
}
catch (Exception ex)
{
Program.Notifier.ShowError($"Failed to save config due to error:\n\n{ex.Message}");
AppState.Notifier.ShowError($"Failed to save config due to error:\n\n{ex.Message}");
}
}
}
Expand Down
41 changes: 41 additions & 0 deletions WitcherScriptMerger.Core/AppState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using WitcherScriptMerger.Inventory;
using WitcherScriptMerger.LoadOrder;
using WitcherScriptMerger.Tools;

namespace WitcherScriptMerger
{
// Shared mutable application state, previously held directly by the host
// project's Program class. It moved out to Core during the Core/host project
// split because domain code that now lives in Core (Paths, AppSettings,
// ModFileIndex, FileMerger, CustomLoadOrder, Cli/MergeOperations,
// Mcp/WsmMcpTools, ...) needs to read/write it, and Core can never reference
// the host assembly (that's the whole point of the split - the dependency only
// flows host -> Core). The host project's Program class re-exposes these as
// pass-through Notifier/Settings/LoadOrder/Inventory properties so none of its
// own call sites had to change.
//
// An explicit static constructor suppresses `beforefieldinit`, so this class's
// field initializers run at a precise, well-defined point (first member access)
// rather than at some unspecified point the CLR chooses - load-bearing here
// because Program.MaybeAttachConsole() must run before Settings' constructor
// can report a missing-config error to the invoking terminal (see CLAUDE.md's
// Startup flow), and because Paths' own static field initializers read
// Settings.Get(...), transitively depending on this class being fully
// initialized first.
public static class AppState
{
// Defaults to the headless implementation so it's safe to use from the very
// first line of Main() - the GUI path swaps it out for MainForm once
// constructed. See CLAUDE.md's IMergeNotifier section.
public static IMergeNotifier Notifier = new HeadlessMergeNotifier();
public static AppSettings Settings = new AppSettings();
public static CustomLoadOrder LoadOrder = null;
public static MergeInventory Inventory = null;

// Set once by the host project at startup (see Program.cs) to a
// KDiff3MergeEngine - see Tools/IMergeEngine.cs for why this exists.
public static IMergeEngine MergeEngine = null;

static AppState() { }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,17 @@ namespace WitcherScriptMerger.Cli
{
// Shared scan/merge orchestration behind both the `merge` CLI verb (Program.cs) and the
// MCP tools (Mcp/WsmMcpTools.cs) - see CLAUDE.md's CLI mode / MCP mode sections.
static class MergeOperations
public static class MergeOperations
{
public static ModFileIndex ScanConflicts()
{
var modIndex = new ModFileIndex();
using (var scanComplete = new ManualResetEventSlim(false))
{
modIndex.BuildAsync(
Program.Settings.Get<bool>("CheckScripts"),
Program.Settings.Get<bool>("CheckXmlFiles"),
Program.Settings.Get<bool>("CheckBundleContents"),
AppState.Settings.Get<bool>("CheckScripts"),
AppState.Settings.Get<bool>("CheckXmlFiles"),
AppState.Settings.Get<bool>("CheckBundleContents"),
(s, e) => { },
(s, e) => scanComplete.Set());
scanComplete.Wait();
Expand All @@ -31,7 +31,10 @@ public static FileMerger.HeadlessMergeSummary RunMerge(
string mergedModName,
IReadOnlyDictionary<string, string[]> orderOverrides)
{
var merger = new FileMerger(inventory, (s, e) => { }, (s, e) => { });
// AppState.MergeEngine is supplied once by the host project at startup
// (Program.cs) - see Tools/IMergeEngine.cs for why Core can't construct
// its one real implementation (KDiff3MergeEngine) itself.
var merger = new FileMerger(inventory, AppState.MergeEngine);
return merger.MergeConflictsHeadless(conflicts, mergedModName, orderOverrides);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,26 @@ public override string ToString()
}
}

static class Categories
// readonly (not just static): callers throughout the codebase compare against
// these by reference equality (e.g. `category == Categories.Script`), and these
// fields are now `public` (required for cross-assembly access after the Core
// split, where they were merely assembly-internal before) - readonly closes off
// any accidental external reassignment silently breaking every such comparison.
public static class Categories
{
public static ModFileCategory Script = new ModFileCategory(
public static readonly ModFileCategory Script = new ModFileCategory(
1, "Scripts", "These plaintext .ws files can be merged", true, false);

public static ModFileCategory Xml = new ModFileCategory(
public static readonly ModFileCategory Xml = new ModFileCategory(
2, "Non-Bundled XML", "These .xml text files can be merged", true, false);

public static ModFileCategory BundleText = new ModFileCategory(
public static readonly ModFileCategory BundleText = new ModFileCategory(
3, "Bundled Text", "These bundled text files can be merged", true, true);

public static ModFileCategory BundleNotMergeable = new ModFileCategory(
public static readonly ModFileCategory BundleNotMergeable = new ModFileCategory(
4, "Bundled Non-text - Not Mergeable", "Right-click mods to define your load order instead of merging", false, true);

public static ModFileCategory FlatNotMergeable = new ModFileCategory(
public static readonly ModFileCategory FlatNotMergeable = new ModFileCategory(
5, "Not Mergeable", "Script Merger doesn't know what these files are", false, false);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

namespace WitcherScriptMerger.FileIndex
{
class ModFileIndex
public class ModFileIndex
{
public List<ModFile> Files;

Expand Down Expand Up @@ -41,7 +41,7 @@ public void BuildAsync(
ModCount = modDirPaths.Count;
if (ModCount == 0)
{
Program.Notifier.ShowMessage("Can't find any mods in the Mods directory.");
AppState.Notifier.ShowMessage("Can't find any mods in the Mods directory.");
}

var bgWorker = new BackgroundWorker
Expand Down Expand Up @@ -127,7 +127,7 @@ private List<ModFile> GetModFilesFromPaths(

private IEnumerable<string> GetIgnoredModNames()
{
var ignoredNames = Program.Settings.Get("IgnoreModNames");
var ignoredNames = AppState.Settings.Get("IgnoreModNames");
return ignoredNames.Split(',')
.Where(name => !string.IsNullOrWhiteSpace(name))
.Select(name => name.Trim());
Expand Down
61 changes: 61 additions & 0 deletions WitcherScriptMerger.Core/HeadlessMergeNotifier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System;

namespace WitcherScriptMerger
{
// Console-based IMergeNotifier for CLI mode. Never blocks on user input -
// every decision has a fixed, non-destructive default (don't overwrite,
// don't use a conflicting merge name, don't retry) so a batch run can
// never hang waiting for a prompt nobody is watching.
class HeadlessMergeNotifier : IMergeNotifier
{
public NotifyResult ShowMessage(string text,
string title = "",
NotifyButtons buttons = NotifyButtons.OK,
DialogIcon icon = DialogIcon.None,
NotifyResult defaultResult = NotifyResult.None)
{
Write(text, title, icon);

// A caller-supplied defaultResult is, by IMergeNotifier's contract, the
// answer that caller considers safe/non-destructive for this specific
// prompt (see IMergeNotifier.ShowMessage's doc comment) - honor it
// directly rather than falling through to the generic per-button-set
// guess below. This matters beyond just respecting the caller's intent:
// for a button set whose generic "safe" answer doesn't actually hold for
// every call site (e.g. YesNoCancel's generic Cancel-is-safest guess is
// wrong for LoadOrderValidator, where Cancel is the one destructive,
// permanent choice), only the caller - not this generic table - actually
// knows which answer is safe.
if (defaultResult != NotifyResult.None)
return defaultResult;

return buttons switch
{
NotifyButtons.OK => NotifyResult.OK,
NotifyButtons.YesNo => NotifyResult.No,
NotifyButtons.YesNoCancel => NotifyResult.Cancel,
NotifyButtons.AbortRetryIgnore => NotifyResult.Abort,
NotifyButtons.RetryCancel => NotifyResult.Cancel,
NotifyButtons.OKCancel => NotifyResult.Cancel,
_ => NotifyResult.Cancel,
};
}

public NotifyResult ShowError(string text, string title = "Error")
{
Write(text, title, DialogIcon.Error);
return NotifyResult.OK;
}

static void Write(string text, string title, DialogIcon icon)
{
var prefix = string.IsNullOrEmpty(title) ? "WSM" : title;
var line = $"[{prefix}] {text}";

if (icon == DialogIcon.Error || icon == DialogIcon.Warning || icon == DialogIcon.Exclamation)
Console.Error.WriteLine(line);
else
Console.WriteLine(line);
}
}
}
42 changes: 42 additions & 0 deletions WitcherScriptMerger.Core/IMergeNotifier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace WitcherScriptMerger
{
// Implemented by HeadlessMergeNotifier (Core, console output, fixed non-destructive
// defaults) and by MainForm (host project, translates to/from real WinForms
// MessageBox.Show(...)/DialogResult around these neutral types) - see CLAUDE.md's
// IMergeNotifier section. Public: MainForm implements this across the Core/host
// assembly boundary.
//
// ShowModal(Form) was deliberately dropped from this interface during the Core
// split: every call site (report-form popups) is GUI-only, interactive code that
// already lives in the host project, so it calls MainForm's ShowModal directly
// instead of going through the notifier abstraction. See the PR description for
// the full reasoning.
//
// IsInteractive was also dropped here (it had zero read call sites anywhere in
// the codebase, before or after the Core split - confirmed dead code, not
// something this split made unused).
public interface IMergeNotifier
{
// defaultResult is the caller's own answer for "which result is safe/
// non-destructive for this specific prompt" - added specifically for
// LoadOrderValidator.PromptToPrioritizeMergedMod, whose YesNoCancel prompt
// has an inverted-from-usual safety shape (Cancel is the one destructive,
// permanent choice there, not Yes/No). NotifyResult.None means "no
// preference, use whatever's generically safe/natural for this button set".
// Both implementations honor it, not just the interactive one:
// - MainForm translates it to the real MessageBoxDefaultButton (which
// button is pre-focused), matching what a direct
// MessageBox.Show(..., MessageBoxDefaultButton) call could do before the
// Core split.
// - HeadlessMergeNotifier returns it directly instead of falling through to
// its own generic per-button-set guess, since only the caller actually
// knows which answer is safe for a prompt like this one.
NotifyResult ShowMessage(string text,
string title = "",
NotifyButtons buttons = NotifyButtons.OK,
DialogIcon icon = DialogIcon.None,
NotifyResult defaultResult = NotifyResult.None);

NotifyResult ShowError(string text, string title = "Error");
}
}
Loading
Loading