diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ddfa61f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +# Default for all files +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +# Markdown: preserve trailing spaces (used for line breaks) +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a4656af..889a410 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -4,6 +4,8 @@ on: push: branches: - main + - pre/** + pull_request: {} jobs: build: @@ -11,24 +13,26 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - - name: Setup .NET - uses: actions/setup-dotnet@v4 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable with: - dotnet-version: 8.x + targets: x86_64-pc-windows-msvc - - name: Restore dependencies - run: dotnet restore - - - name: Run unit tests - run: dotnet test .\Tests\MulderConfigTests.csproj -c Release --no-restore + - name: Run tests + run: cargo test - name: Generate Version id: generate_version shell: pwsh run: | - $base = Get-Date -Format "yy.MM" + $isPre = "${{ github.ref_name }}" -ne "main" + if ($isPre) { + $base = Get-Date -Format "yy.00" + } else { + $base = Get-Date -Format "yy.MM" + } git fetch --tags $tags = git tag -l "$base.*" Write-Host "Tags: $($tags -join ', ')" @@ -41,30 +45,45 @@ jobs: $version = "$base.$next" Write-Host "Generated version: $version" echo version=$version >> $env:GITHUB_OUTPUT + echo is_pre=$($isPre.ToString().ToLower()) >> $env:GITHUB_OUTPUT - - name: Publish (framework-dependent, single file) - run: dotnet publish ./MulderConfig.csproj ` - -c Release ` - -r win-x64 ` - --self-contained false ` - -p:SelfContained=false ` - -p:PublishSingleFile=true ` - -p:DebugType=none ` - -p:Version=${{ steps.generate_version.outputs.version }} ` - -o publish + - name: Compile executable + env: + APP_VERSION: ${{ steps.generate_version.outputs.version }} + run: cargo build --release - name: Sign executable + if: vars.ENABLE_CODE_SIGNING == 'true' shell: pwsh run: | [Byte[]] $bytes = [System.Convert]::FromBase64String("${{ secrets.CERTIFICATE_BASE64 }}") [IO.File]::WriteAllBytes("mulderload.pfx", $bytes) - Start-Process "C:/Program Files (x86)/Microsoft SDKs/ClickOnce/SignTool/signtool.exe" "sign /f mulderload.pfx /p ${{ secrets.CERTIFICATE_PASSWORD }} /t http://timestamp.digicert.com /fd sha256 publish/MulderConfig.exe" -PassThru -Wait + Start-Process "C:/Program Files (x86)/Microsoft SDKs/ClickOnce/SignTool/signtool.exe" "sign /f mulderload.pfx /p ${{ secrets.CERTIFICATE_PASSWORD }} /t http://timestamp.digicert.com /fd sha256 target/release/MulderConfig.exe" -PassThru -Wait - name: Create Release - uses: softprops/action-gh-release@v2.3.3 + uses: softprops/action-gh-release@v3 with: + files: target/release/MulderConfig.exe + generate_release_notes: true + prerelease: ${{ steps.generate_version.outputs.is_pre }} tag_name: ${{ steps.generate_version.outputs.version }} - files: publish/MulderConfig.exe + target_commitish: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Cleanup old pre-releases + if: steps.generate_version.outputs.is_pre == 'true' + shell: pwsh + run: | + $old = gh release list --limit 100 --json tagName,isPrerelease,createdAt | + ConvertFrom-Json | + Where-Object { $_.isPrerelease } | + Sort-Object createdAt | + Select-Object -SkipLast 10 + foreach ($r in $old) { + Write-Host "Deleting old pre-release: $($r.tagName)" + gh release delete $r.tagName --cleanup-tag --yes + } + env: + GH_TOKEN: ${{ github.token }} permissions: contents: write diff --git a/.gitignore b/.gitignore index 47a94ef..ea8c4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,428 +1 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore - -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates -*.env - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ - -[Dd]ebug/x64/ -[Dd]ebugPublic/x64/ -[Rr]elease/x64/ -[Rr]eleases/x64/ -bin/x64/ -obj/x64/ - -[Dd]ebug/x86/ -[Dd]ebugPublic/x86/ -[Rr]elease/x86/ -[Rr]eleases/x86/ -bin/x86/ -obj/x86/ - -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -[Aa][Rr][Mm]64[Ee][Cc]/ -bld/ -[Oo]bj/ -[Oo]ut/ -[Ll]og/ -[Ll]ogs/ - -# Build results on 'Bin' directories -**/[Bb]in/* -# Uncomment if you have tasks that rely on *.refresh files to move binaries -# (https://github.com/github/gitignore/pull/3736) -#!**/[Bb]in/*.refresh - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* -*.trx - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Approval Tests result files -*.received.* - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.idb -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -# but not Directory.Build.rsp, as it configures directory-level build defaults -!Directory.Build.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -**/.paket/paket.exe -paket-files/ - -# FAKE - F# Make -**/.fake/ - -# CodeRush personal settings -**/.cr/personal - -# Python Tools for Visual Studio (PTVS) -**/__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -#tools/** -#!tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog -MSBuild_Logs/ - -# AWS SAM Build and Temporary Artifacts folder -.aws-sam - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -**/.mfractor/ - -# Local History for Visual Studio -**/.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -**/.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets - -# Local History for Visual Studio Code -.history/ - -# Built Visual Studio Code Extensions -*.vsix - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp +/target diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..6b79f99 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "editorconfig.editorconfig", + "rust-lang.rust-analyzer", + "vadimcn.vscode-lldb" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8582900 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "files.eol": "\n" +} diff --git a/Actions/FileOperationManager.cs b/Actions/FileOperationManager.cs deleted file mode 100644 index 27f8f39..0000000 --- a/Actions/FileOperationManager.cs +++ /dev/null @@ -1,268 +0,0 @@ -using System.Text.RegularExpressions; -using MulderConfig.Configuration; -using MulderConfig.Logic; - -namespace MulderConfig.Actions; - -public class FileOperationManager -{ - public void ExecuteOperations(List operations, IReadOnlyDictionary selected) - { - foreach (var action in operations) - { - if (action.When != null && !WhenResolver.Match(action.When, selected)) - continue; - - try - { - switch ((action.Operation ?? string.Empty).ToLower()) - { - case "setreadonly": - ExecuteSetReadOnly(action, isReadOnly: true); - break; - - case "removereadonly": - ExecuteSetReadOnly(action, isReadOnly: false); - break; - - case "rename": - case "move": - ExecuteMove(action); - break; - - case "copy": - ExecuteCopy(action); - break; - - case "delete": - ExecuteDelete(action); - break; - - case "replaceline": - ExecuteReplaceLine(action); - break; - - case "removeline": - ExecuteRemoveLine(action); - break; - - case "replacetext": - ExecuteReplaceText(action); - break; - - default: - MessageBox.Show($"Unknown operation: {action.Operation}", "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning); - break; - } - } - catch (Exception ex) - { - MessageBox.Show($"Operation failed: {action.Operation}\n{ex.Message}", "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning); - } - } - } - - private static string ResolvePath(string path) - { - if (string.IsNullOrWhiteSpace(path)) - return path; - - // Expand Windows env vars like %USERPROFILE% - path = Environment.ExpandEnvironmentVariables(path); - - if (Path.IsPathRooted(path)) - return Path.GetFullPath(path); - - return Path.GetFullPath(Path.Combine(Application.StartupPath, path)); - } - - private static void ExecuteSetReadOnly(OperationAction action, bool isReadOnly) - { - if (action.Files == null || action.Files.Count == 0) - throw new InvalidOperationException("Missing 'files' for SetReadOnly/RemoveReadOnly."); - - foreach (var f in action.Files) - { - if (string.IsNullOrWhiteSpace(f)) - continue; - - var path = ResolvePath(f); - - // Idempotent behavior: if the target doesn't exist, we do nothing. - if (!File.Exists(path)) - continue; - - var attrs = File.GetAttributes(path); - var readOnlyBit = FileAttributes.ReadOnly; - - var newAttrs = isReadOnly - ? (attrs | readOnlyBit) - : (attrs & ~readOnlyBit); - - if (newAttrs != attrs) - File.SetAttributes(path, newAttrs); - } - } - - private static void ExecuteMove(OperationAction action) - { - if (string.IsNullOrWhiteSpace(action.Source) || string.IsNullOrWhiteSpace(action.Target)) - throw new InvalidOperationException("Missing 'source' or 'target' for rename/move."); - - var sourcePath = ResolvePath(action.Source); - var targetPath = ResolvePath(action.Target); - - var targetParent = Path.GetDirectoryName(targetPath); - if (!string.IsNullOrWhiteSpace(targetParent)) - Directory.CreateDirectory(targetParent); - - if (File.Exists(sourcePath)) - { - if (File.Exists(targetPath)) - File.Delete(targetPath); - else if (Directory.Exists(targetPath)) - Directory.Delete(targetPath, recursive: true); - - File.Move(sourcePath, targetPath); - return; - } - - if (Directory.Exists(sourcePath)) - { - if (Directory.Exists(targetPath)) - Directory.Delete(targetPath, recursive: true); - else if (File.Exists(targetPath)) - File.Delete(targetPath); - - Directory.Move(sourcePath, targetPath); - return; - } - - // Idempotent behavior: if the target doesn't exist, we do nothing. - return; - } - - private static void ExecuteCopy(OperationAction action) - { - if (string.IsNullOrWhiteSpace(action.Source) || string.IsNullOrWhiteSpace(action.Target)) - throw new InvalidOperationException("Missing 'source' or 'target' for copy."); - - var sourcePath = ResolvePath(action.Source); - var targetPath = ResolvePath(action.Target); - - if (!File.Exists(sourcePath)) - return; - - File.Copy(sourcePath, targetPath, overwrite: true); - } - - private static void ExecuteDelete(OperationAction action) - { - if (string.IsNullOrWhiteSpace(action.Source)) - throw new InvalidOperationException("Missing 'source' for delete."); - - var sourcePath = ResolvePath(action.Source); - if (File.Exists(sourcePath)) - File.Delete(sourcePath); - } - - private void ExecuteReplaceLine(OperationAction action) - { - if (string.IsNullOrWhiteSpace(action.Pattern) || action.Replacement == null) - throw new InvalidOperationException("Missing 'pattern' or 'replacement' for replaceLine."); - - foreach (var filePath in ResolveFiles(action.Files)) - ReplaceLineInFile(filePath, action.Pattern, action.Replacement); - } - - private void ExecuteRemoveLine(OperationAction action) - { - if (string.IsNullOrWhiteSpace(action.Pattern)) - throw new InvalidOperationException("Missing 'pattern' for removeLine."); - - foreach (var filePath in ResolveFiles(action.Files)) - RemoveLineInFile(filePath, action.Pattern); - } - - private void ExecuteReplaceText(OperationAction action) - { - if (action.Search == null || action.Replacement == null) - throw new InvalidOperationException("Missing 'search' or 'replacement' for replaceText."); - - foreach (var filePath in ResolveFiles(action.Files)) - ReplaceTextInFile(filePath, action.Search, action.Replacement); - } - - private static IEnumerable ResolveFiles(List? files) - { - if (files == null || files.Count == 0) - yield break; - - foreach (var f in files) - { - if (string.IsNullOrWhiteSpace(f)) - continue; - - var filePath = ResolvePath(f); - if (!File.Exists(filePath)) - { - MessageBox.Show($"File not found: {filePath}", "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning); - continue; - } - - yield return filePath; - } - } - - private void ReplaceLineInFile(string filePath, string pattern, string replacement) - { - var lines = File.ReadAllLines(filePath).ToList(); - var regex = new Regex(pattern, RegexOptions.IgnoreCase); - - var interpretedReplacement = Regex.Unescape(replacement); - var replacementLines = interpretedReplacement - .Split('\n') - .Where(l => !string.IsNullOrEmpty(l)) - .ToList(); - - bool modified = false; - var newLines = new List(); - - for (int i = 0; i < lines.Count; i++) - { - if (regex.IsMatch(lines[i])) - { - modified = true; - if (replacementLines.Count > 0) - newLines.AddRange(replacementLines); - } - else - { - newLines.Add(lines[i]); - } - } - - if (modified) - File.WriteAllLines(filePath, newLines.ToArray()); - } - - private void RemoveLineInFile(string filePath, string pattern) - { - var lines = File.ReadAllLines(filePath).ToList(); - var regex = new Regex(pattern, RegexOptions.IgnoreCase); - - var newLines = lines.Where(line => !regex.IsMatch(line)).ToList(); - - if (newLines.Count < lines.Count) - File.WriteAllLines(filePath, newLines.ToArray()); - } - - private void ReplaceTextInFile(string filePath, string search, string replacement) - { - var content = File.ReadAllText(filePath); - var newContent = content.Replace(search, replacement); - - if (content != newContent) - File.WriteAllText(filePath, newContent); - } -} diff --git a/Actions/LaunchManager.cs b/Actions/LaunchManager.cs deleted file mode 100644 index 14601fa..0000000 --- a/Actions/LaunchManager.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System.Diagnostics; -using MulderConfig.Configuration; -using MulderConfig.Logic; -using MulderConfig.Apply; -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("MulderConfigTests")] - -namespace MulderConfig.Actions; - -public class LaunchManager(ConfigModel config, string title, IReadOnlyDictionary choices) -{ - public void Launch() - { - var (exePath, workDir, wait, args) = ResolveLaunch(); - - if (!File.Exists(exePath)) - { - throw new FileNotFoundException("Can't find executable.", exePath); - } - - Process process = new() - { - StartInfo = new ProcessStartInfo - { - FileName = exePath, - WorkingDirectory = workDir, - Arguments = args, - UseShellExecute = false - } - }; - process.Start(); - - if (wait) - { - process.WaitForExit(); - } - } - - internal (string exePath, string workDir, bool wait, string args) ResolveLaunch() - { - var selected = new Dictionary(choices, StringComparer.OrdinalIgnoreCase) - { - ["Title"] = title - }; - - // Defaults - var exePath = MakePath(config.Game.OriginalExe); - var workDir = Application.StartupPath; - bool wait = false; - var args = new List(); - - foreach (var rule in config.Actions.Launch) - { - if (rule.When != null && !WhenResolver.Match(rule.When, selected)) - continue; - - // Atomic override: last match wins - if (rule.Exec != null) - { - exePath = MakePath(rule.Exec.Name); - workDir = MakePath(rule.Exec.WorkDir); - wait = rule.Exec.Wait ?? false; - } - - // Cumulative: append all matching args - if (rule.Args != null) - { - foreach (var a in rule.Args) - { - if (!string.IsNullOrWhiteSpace(a)) - args.Add(a); - } - } - } - - return (exePath, workDir, wait, string.Join(" ", args)); - } - - private static string MakePath(string path) - { - if (Path.IsPathRooted(path)) - return Path.GetFullPath(path); - - return Path.GetFullPath(Path.Combine(Application.StartupPath, path)); - } -} diff --git a/Apply/ApplyManager.cs b/Apply/ApplyManager.cs deleted file mode 100644 index 204c24e..0000000 --- a/Apply/ApplyManager.cs +++ /dev/null @@ -1,32 +0,0 @@ -using MulderConfig.Actions; -using MulderConfig.Configuration; -using MulderConfig.Save; - -namespace MulderConfig.Apply; - -public sealed class ApplyManager( - ConfigModel config, - ExeReplacer exeReplacer, - FileOperationManager FileOperationManager) -{ - public void Apply(ISelectionProvider selectionProvider) - { - Apply(selectionProvider.GetTitle(), selectionProvider.GetChoices()); - } - - public void Apply(string title, IReadOnlyDictionary choices) - { - var selected = new Dictionary(choices, StringComparer.OrdinalIgnoreCase) - { - ["Title"] = title - }; - - var operations = config.Actions.Operations; - if (operations != null && operations.Count > 0) - FileOperationManager.ExecuteOperations(operations, selected); - - // If there is no launch section/rules, there is no exe replacement to perform. - if ((config.Actions.Launch?.Count ?? 0) > 0 && !exeReplacer.IsReplaced()) - exeReplacer.Replace(); - } -} diff --git a/Apply/ExeReplacer.cs b/Apply/ExeReplacer.cs deleted file mode 100644 index d56c30e..0000000 --- a/Apply/ExeReplacer.cs +++ /dev/null @@ -1,83 +0,0 @@ -using MulderConfig.Configuration; - -namespace MulderConfig.Apply; - -public class ExeReplacer(ConfigModel config) -{ - private const string LAUNCHER_NAME = "MulderConfig"; - - private (string originalExe, string targetExe) GetExePaths() - { - var originalExe = Path.Combine(Application.StartupPath, config.Game.OriginalExe); - var targetExeName = Path.GetFileNameWithoutExtension(config.Game.OriginalExe) + "_o" + Path.GetExtension(config.Game.OriginalExe); - var targetExe = Path.Combine(Application.StartupPath, targetExeName); - - return (originalExe, targetExe); - } - - public (string originalExe, string targetExe) GetExePathsPublic() => GetExePaths(); - - public string GetDefaultLaunchExePath() - { - var (originalExe, targetExe) = GetExePaths(); - return IsReplaced() && File.Exists(targetExe) ? targetExe : originalExe; - } - - private string GetLauncherPath() - { - return Path.Combine(Application.StartupPath, $"{LAUNCHER_NAME}.exe"); - } - - private bool FilesEquals(string path1, string path2) - { - var fileInfo1 = new FileInfo(path1); - var fileInfo2 = new FileInfo(path2); - - if (fileInfo1.Length != fileInfo2.Length) - return false; - - // TODO compare checksums - - return true; - } - - public bool IsReplaced() - { - var (originalExe, targetExe) = GetExePaths(); - - if (!File.Exists(originalExe) || !File.Exists(targetExe)) - return false; - - var launcherExe = GetLauncherPath(); - return FilesEquals(originalExe, launcherExe); - } - - public bool CanReplace() - { - var processName = System.Diagnostics.Process.GetCurrentProcess().ProcessName; - if (!processName.Equals(LAUNCHER_NAME, StringComparison.OrdinalIgnoreCase)) - return false; - - var (originalExe, _) = GetExePaths(); - return File.Exists(originalExe); - } - - public void Replace() - { - if (!CanReplace()) - return; - - var (originalExe, targetExe) = GetExePaths(); - File.Move(originalExe, targetExe, true); - - try - { - File.Copy(GetLauncherPath(), originalExe, true); - MessageBox.Show("Replacement done.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); - } - catch (Exception ex) - { - MessageBox.Show(ex.Message, "Warning: Replacement partially failed", MessageBoxButtons.OK, MessageBoxIcon.Warning); - } - } -} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..fc25fb6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,455 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libm" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc7aa29613bd6a620df431842069224d8bc9011086b1db4c0e0cd47fa03ec9a" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mulder-config" +version = "26.5.0-dev" +dependencies = [ + "indexmap", + "native-windows-derive", + "native-windows-gui", + "regex-lite", + "serde", + "serde_json", + "windows-sys", + "winres", +] + +[[package]] +name = "native-windows-derive" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76134ae81020d89d154f619fd2495a2cecad204276b1dc21174b55e4d0975edd" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "native-windows-gui" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f7003a669f68deb6b7c57d74fff4f8e533c44a3f0b297492440ef4ff5a28454" +dependencies = [ + "bitflags", + "lazy_static", + "newline-converter", + "plotters", + "plotters-backend", + "stretch", + "winapi", + "winapi-build", +] + +[[package]] +name = "newline-converter" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f71d09d5c87634207f894c6b31b6a2b2c64ea3bdcf71bd5599fdbbe1600c00f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "proc-macro-crate" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6ea3c4595b96363c13943497db34af4460fb474a95c43f4446ad341b8c9785" +dependencies = [ + "toml", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "stretch" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b0dc6d20ce137f302edf90f9cd3d278866fd7fb139efca6f246161222ad6d87" +dependencies = [ + "lazy_static", + "libm", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "wasm-bindgen" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-build" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winres" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c" +dependencies = [ + "toml", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..130f5d0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "mulder-config" +version = "26.5.0-dev" +edition = "2024" + +[[bin]] +name = "MulderConfig" +path = "src/main.rs" + +[dependencies] +indexmap = { version = "2", features = ["serde"] } +native-windows-derive = "1.0.5" +native-windows-gui = "1.0.13" +regex-lite = "0.1" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Shell", "Win32_UI_Controls", "Win32_System_LibraryLoader", "Win32_UI_Input_KeyboardAndMouse", "Win32_Storage_FileSystem"] } + +[build-dependencies] +winres = "0.1" diff --git a/Configuration/ConfigModel.cs b/Configuration/ConfigModel.cs deleted file mode 100644 index 599dd89..0000000 --- a/Configuration/ConfigModel.cs +++ /dev/null @@ -1,85 +0,0 @@ -using Newtonsoft.Json; - -namespace MulderConfig.Configuration; - -public class ConfigModel -{ - public required Game Game { get; set; } - public List? Addons { get; set; } - public required List OptionGroups { get; set; } - public required ActionRoot Actions { get; set; } -} - -public class Game -{ - public required string Title { get; set; } - public required string OriginalExe { get; set; } -} - -public class Addon -{ - public required string Title { get; set; } - public int SteamId { get; set; } -} - -public class OptionGroup -{ - public required string Name { get; set; } - public required string Type { get; set; } // "radioGroup" | "checkboxGroup" - public List? Radios { get; set; } - public List? Checkboxes { get; set; } -} - -public class Radio -{ - public required string Value { get; set; } - public List? DisabledWhen { get; set; } -} - -public class Checkbox -{ - public required string Value { get; set; } - public List? DisabledWhen { get; set; } -} - -public class WhenGroup : Dictionary -{ -} - -public class ActionRoot -{ - public List Launch { get; set; } = new(); - public List Operations { get; set; } = new(); -} - -public class LaunchAction -{ - public List? When { get; set; } - - // Atomic override: if present, it defines both exe name + working directory - public ExecSpec? Exec { get; set; } - - // Cumulative: appended to the final args in JSON order - public List? Args { get; set; } -} - -public class ExecSpec -{ - public required string Name { get; set; } - public required string WorkDir { get; set; } - public bool? Wait { get; set; } = false; -} - -public class OperationAction -{ - public List? When { get; set; } - public required string Operation { get; set; } - - public string? Source { get; set; } - public string? Target { get; set; } - - public List? Files { get; set; } - public string? Pattern { get; set; } - public string? Search { get; set; } - public string? Replacement { get; set; } -} diff --git a/Configuration/ConfigProvider.cs b/Configuration/ConfigProvider.cs deleted file mode 100644 index f7f8d85..0000000 --- a/Configuration/ConfigProvider.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Newtonsoft.Json; - -namespace MulderConfig.Configuration; - -public class ConfigProvider -{ - private const string FILENAME = "MulderConfig.json"; - - public static ConfigModel GetConfig() - { - string configPath = Path.Combine(Application.StartupPath, FILENAME); - - if (!File.Exists(configPath)) - throw new FileNotFoundException($"The file '{FILENAME}' does not exist.", configPath); - - try - { - string json = File.ReadAllText(configPath); - return JsonConvert.DeserializeObject(json) ?? throw new InvalidDataException($"The file '{FILENAME}' is empty or invalid."); - } - catch (JsonException) - { - throw new Exception($"The file '{FILENAME}' is an invalid json."); - } - } -} diff --git a/Configuration/ConfigValidator.cs b/Configuration/ConfigValidator.cs deleted file mode 100644 index 5c1d8e9..0000000 --- a/Configuration/ConfigValidator.cs +++ /dev/null @@ -1,161 +0,0 @@ -namespace MulderConfig.Configuration; - -public class ConfigValidator -{ - public static bool IsValid(ConfigModel config) - { - if (config == null) - return false; - - if (config.Game == null) - return false; - - if (string.IsNullOrWhiteSpace(config.Game.Title)) - return false; - - if (string.IsNullOrWhiteSpace(config.Game.OriginalExe)) - return false; - - // addons is optional - if (config.Addons != null) - { - if (config.Addons.Any(a => a == null || string.IsNullOrWhiteSpace(a.Title))) - return false; - } - - if (config.OptionGroups == null) - return false; - - if (config.Actions == null) - return false; - - // actions.launch and actions.operations are optional; if missing/null they are treated as empty lists. - // However, having no actions at all makes no sense: require at least one action. - var launchCount = config.Actions.Launch?.Count ?? 0; - var operationsCount = config.Actions.Operations?.Count ?? 0; - if (launchCount == 0 && operationsCount == 0) - return false; - - var groupNames = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var group in config.OptionGroups) - { - if (group == null) - return false; - - if (string.IsNullOrWhiteSpace(group.Name)) - return false; - - if (!groupNames.Add(group.Name)) - return false; - - if (group.Type != "radioGroup" && group.Type != "checkboxGroup") - return false; - - if (group.Type == "radioGroup") - { - if (group.Radios == null || group.Radios.Count == 0) - return false; - - var values = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var r in group.Radios) - { - if (r == null || string.IsNullOrWhiteSpace(r.Value)) - return false; - - if (!values.Add(r.Value)) - return false; - } - } - - if (group.Type == "checkboxGroup") - { - if (group.Checkboxes == null || group.Checkboxes.Count == 0) - return false; - - var values = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var c in group.Checkboxes) - { - if (c == null || string.IsNullOrWhiteSpace(c.Value)) - return false; - - if (!values.Add(c.Value)) - return false; - } - } - } - - foreach (var rule in config.Actions.Launch ?? Enumerable.Empty()) - { - if (rule == null) - return false; - - if (rule.Exec != null) - { - if (string.IsNullOrWhiteSpace(rule.Exec.Name)) - return false; - - if (string.IsNullOrWhiteSpace(rule.Exec.WorkDir)) - return false; - } - - if (rule.Args != null && rule.Args.Any(a => a == null)) - return false; - } - - foreach (var op in config.Actions.Operations ?? Enumerable.Empty()) - { - if (op == null) - return false; - - if (string.IsNullOrWhiteSpace(op.Operation)) - return false; - - var operation = op.Operation.Trim().ToLowerInvariant(); - - if (operation is "rename" or "move" or "copy") - { - if (string.IsNullOrWhiteSpace(op.Source) || string.IsNullOrWhiteSpace(op.Target)) - return false; - } - - if (operation is "delete") - { - if (string.IsNullOrWhiteSpace(op.Source)) - return false; - } - - if (operation is "setreadonly" or "removereadonly") - { - if (op.Files == null || op.Files.Count == 0) - return false; - } - - if (operation is "replaceline" or "removeline") - { - if (op.Files == null || op.Files.Count == 0) - return false; - - if (string.IsNullOrWhiteSpace(op.Pattern)) - return false; - - if (operation == "replaceline" && op.Replacement == null) - return false; - } - - if (operation is "replacetext") - { - if (op.Files == null || op.Files.Count == 0) - return false; - - if (string.IsNullOrWhiteSpace(op.Search)) - return false; - - if (op.Replacement == null) - return false; - } - } - - return true; - } -} diff --git a/ISelectionProvider.cs b/ISelectionProvider.cs deleted file mode 100644 index 3206ebc..0000000 --- a/ISelectionProvider.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace MulderConfig; - -public interface ISelectionProvider -{ - string GetTitle(); // GameTitle or AddonTitle - Dictionary GetChoices(); -} diff --git a/Logic/WhenResolver.cs b/Logic/WhenResolver.cs deleted file mode 100644 index 23694f6..0000000 --- a/Logic/WhenResolver.cs +++ /dev/null @@ -1,111 +0,0 @@ -using MulderConfig.Configuration; - -namespace MulderConfig.Logic; - - -public static class WhenResolver -{ - public static bool Match(List groups, IReadOnlyDictionary selected) - { - if (groups == null || groups.Count == 0) - return true; // Empty `when` means "always apply". - - return groups.Any(group => IsGroupMatch(group, selected)); - } - - private static bool IsGroupMatch(WhenGroup group, IReadOnlyDictionary selected) - { - foreach (var kvp in group) - { - var rawKey = kvp.Key; - var expected = kvp.Value ?? string.Empty; - - var (op, key) = ParseKey(rawKey); - - bool hasKey = selected.TryGetValue(key, out var selectedValue); - - // Special case: expected is "" and operator is Equals => "nothing selected". - // This is mainly for checkbox groups where an empty list means "no selection". - if (op == ConditionOperator.Equals && string.IsNullOrEmpty(expected)) - { - if (IsNullOrEmptySelection(selectedValue)) - continue; - - return false; - } - - // Missing key (or null value): - // - Equals / Contains => cannot match - // - NotEquals / NotContains => considered true ("different" / "does not contain") - if (!hasKey || selectedValue == null) - { - if (op == ConditionOperator.NotEquals || op == ConditionOperator.NotContains) - continue; - - return false; - } - - if (!IsValueMatch(selectedValue, expected, op)) - return false; - } - - return true; - } - - private static bool IsNullOrEmptySelection(object? selectedValue) - { - if (selectedValue == null) - return true; - if (selectedValue is List list && list.Count == 0) - return true; - return false; - } - - private static bool IsValueMatch(object selectedValue, string expected, ConditionOperator op) - { - if (selectedValue is List list) - { - return op switch - { - ConditionOperator.Contains => list.Any(v => ContainsIgnoreCase(v, expected)), - ConditionOperator.NotContains => !list.Any(v => ContainsIgnoreCase(v, expected)), - ConditionOperator.NotEquals => !list.Contains(expected, StringComparer.OrdinalIgnoreCase), - _ => list.Contains(expected, StringComparer.OrdinalIgnoreCase), - }; - } - - var actual = selectedValue.ToString() ?? string.Empty; - return op switch - { - ConditionOperator.Contains => ContainsIgnoreCase(actual, expected), - ConditionOperator.NotContains => !ContainsIgnoreCase(actual, expected), - ConditionOperator.NotEquals => !EqualsIgnoreCase(actual, expected), - _ => EqualsIgnoreCase(actual, expected), - }; - } - - private static (ConditionOperator op, string key) ParseKey(string rawKey) - { - if (rawKey.StartsWith("!*")) - return (ConditionOperator.NotContains, rawKey.TrimStart('!', '*')); - if (rawKey.StartsWith("*")) - return (ConditionOperator.Contains, rawKey.TrimStart('*')); - if (rawKey.StartsWith("!")) - return (ConditionOperator.NotEquals, rawKey.TrimStart('!')); - return (ConditionOperator.Equals, rawKey); - } - - private static bool ContainsIgnoreCase(string actual, string expected) => - actual.IndexOf(expected, StringComparison.OrdinalIgnoreCase) >= 0; - - private static bool EqualsIgnoreCase(string a, string b) => - string.Equals(a, b, StringComparison.OrdinalIgnoreCase); - - private enum ConditionOperator - { - Equals, - NotEquals, - Contains, - NotContains, - } -} diff --git a/ModeDetector.cs b/ModeDetector.cs deleted file mode 100644 index c3e9865..0000000 --- a/ModeDetector.cs +++ /dev/null @@ -1,24 +0,0 @@ -using MulderConfig.Configuration; -using System.Diagnostics; - -namespace MulderConfig; - -public sealed class ModeDetector(ConfigModel config, string[] args) -{ - public bool IsLaunchMode() - { - if (args.Any(a => a.Equals("-launch", StringComparison.OrdinalIgnoreCase))) { - return true; // useful for local test - } - - var originalExeName = Path.GetFileName(config.Game.OriginalExe); - var processExeName = Process.GetCurrentProcess().ProcessName + ".exe"; - - return originalExeName.Equals(processExeName, StringComparison.OrdinalIgnoreCase); - } - - public bool IsApplyMode() - { - return args.Any(a => a.Equals("-apply", StringComparison.OrdinalIgnoreCase)); - } -} diff --git a/MulderConfig.csproj b/MulderConfig.csproj deleted file mode 100644 index 5374919..0000000 --- a/MulderConfig.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - WinExe - net8.0-windows - enable - true - enable - - favicon.ico - $(DefaultItemExcludes);Tests\** - - - - - - - - - - True - \ - - - \ No newline at end of file diff --git a/MulderConfig.sln b/MulderConfig.sln deleted file mode 100644 index 7374c85..0000000 --- a/MulderConfig.sln +++ /dev/null @@ -1,28 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.12.35514.174 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MulderConfig", "MulderConfig.csproj", "{756D4323-BB54-47B1-8923-4738973DB42A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MulderConfigTests", "Tests\MulderConfigTests.csproj", "{57E5364F-99FE-4DC3-B2F1-2CEB7769160D}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {756D4323-BB54-47B1-8923-4738973DB42A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {756D4323-BB54-47B1-8923-4738973DB42A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {756D4323-BB54-47B1-8923-4738973DB42A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {756D4323-BB54-47B1-8923-4738973DB42A}.Release|Any CPU.Build.0 = Release|Any CPU - {57E5364F-99FE-4DC3-B2F1-2CEB7769160D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {57E5364F-99FE-4DC3-B2F1-2CEB7769160D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {57E5364F-99FE-4DC3-B2F1-2CEB7769160D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {57E5364F-99FE-4DC3-B2F1-2CEB7769160D}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal diff --git a/Program.cs b/Program.cs deleted file mode 100644 index efc95ed..0000000 --- a/Program.cs +++ /dev/null @@ -1,87 +0,0 @@ -using MulderConfig.Actions; -using MulderConfig.Configuration; -using MulderConfig.Save; -using MulderConfig.Apply; -using MulderConfig.UI; -using MulderConfig; - -namespace MulderConfig; - -internal static class Program -{ - /// - /// The main entry point for the application. - /// - [STAThread] - static void Main(string[] args) - { - // Read and validate config - ConfigModel config; - try - { - config = ConfigProvider.GetConfig(); - } - catch (Exception ex) - { - MessageBox.Show($"Error loading configuration:\n{ex.Message}"); - return; - } - if (!ConfigValidator.IsValid(config)) - { - MessageBox.Show("Error loading configuration:\nThe file 'MulderConfig.json' has invalid data."); - return; - } - - // Initialize core components - var exeReplacer = new ExeReplacer(config); - var fileOperationManager = new FileOperationManager(); - var modeDetector = new ModeDetector(config, args); - var saveLoader = new SaveLoader(); - var saveSaver = new SaveSaver(saveLoader); - var steamAddonHandler = new SteamAddonHandler(config, args); - var applyManager = new ApplyManager(config, exeReplacer, fileOperationManager); - - // Handle Steam Addons - var steamAddonId = steamAddonHandler.ResolveAddonId(); - var title = steamAddonHandler.ResolveAddonTitle(steamAddonId) ?? config.Game.Title; - - // Select current addon save - saveLoader.LoadAll(); - var save = saveLoader.Load(title); - if (!SaveValidator.IsValid(config, save)) - { - MessageBox.Show($"Invalid configuration for {title}.\nThe save file may be corrupted (delete MulderConfig.save.json)."); - return; - } - - // Run App - if (modeDetector.IsApplyMode()) - { - applyManager.Apply(title, save); - } - else if (modeDetector.IsLaunchMode()) - { - var launchManager = new LaunchManager(config, title, save); - launchManager.Launch(); - } - else - { - // Initialize UI components - var formSelectionProvider = new FormSelectionProvider(config); - var formValidator = new FormValidator(config, formSelectionProvider); - var formBuilder = new FormBuilder(formValidator, formSelectionProvider); - - // Normal UI mode - ApplicationConfiguration.Initialize(); - Application.Run(new Form1( - title, - config, - applyManager, - formBuilder, - formValidator, - formSelectionProvider, - saveLoader, - saveSaver)); - } - } -} diff --git a/Save/SaveLoader.cs b/Save/SaveLoader.cs deleted file mode 100644 index 5db3c7a..0000000 --- a/Save/SaveLoader.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace MulderConfig.Save; - -public sealed class SaveLoader -{ - private Dictionary>? _saves; - - public Dictionary> LoadAll() - { - if (_saves != null) - return _saves; - - var savePath = GetSavePath(); - - if (!File.Exists(savePath)) - { - _saves = new Dictionary>(StringComparer.OrdinalIgnoreCase); - return _saves; - } - - try - { - var json = File.ReadAllText(savePath); - _saves = JsonConvert.DeserializeObject>>(json) - ?? new Dictionary>(StringComparer.OrdinalIgnoreCase); - } - catch - { - _saves = new Dictionary>(StringComparer.OrdinalIgnoreCase); - } - - return _saves; - } - - public Dictionary Load(string addon) - { - if (_saves == null) - throw new InvalidOperationException("LoadAll must be called before Load."); - - if (!_saves.TryGetValue(addon, out var save)) - return new Dictionary(StringComparer.OrdinalIgnoreCase); - - var normalized = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var entry in save) - { - normalized[entry.Key] = NormalizeValue(entry.Value); - } - - return normalized; - } - - internal void SetCache(Dictionary> newCache) - { - _saves = newCache; - } - - private static string GetSavePath() - { - return Path.Combine(Application.StartupPath, "MulderConfig.save.json"); - } - - private static object? NormalizeValue(object? value) - { - if (value is JArray array) - { - return array.Values().Where(v => v != null).Select(v => v!).ToList(); - } - - if (value is JValue jValue) - { - return jValue.Type == JTokenType.Null ? null : jValue.ToObject(); - } - - if (value is IList list) - { - return list.Select(v => v?.ToString()).Where(v => v != null).Select(v => v!).ToList(); - } - - return value; - } -} diff --git a/Save/SaveModel.cs b/Save/SaveModel.cs deleted file mode 100644 index 6516a45..0000000 --- a/Save/SaveModel.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace MulderConfig.Save; - -public class SaveModel -{ - public Dictionary> AddonSelections { get; set; } - = new Dictionary>(StringComparer.OrdinalIgnoreCase); -} diff --git a/Save/SaveSaver.cs b/Save/SaveSaver.cs deleted file mode 100644 index dca23ab..0000000 --- a/Save/SaveSaver.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Newtonsoft.Json; - -namespace MulderConfig.Save; - -public sealed class SaveSaver(SaveLoader loader) -{ - public void SaveChoices(string addon, Dictionary choices) - { - var saves = loader.LoadAll(); - - saves[addon] = new Dictionary(choices, StringComparer.OrdinalIgnoreCase); - - var json = JsonConvert.SerializeObject(saves, Formatting.Indented); - File.WriteAllText(GetSavePath(), json); - - // keep loader cache in sync - loader.SetCache(saves); - } - - private static string GetSavePath() - { - return Path.Combine(Application.StartupPath, "MulderConfig.save.json"); - } -} diff --git a/Save/SaveValidator.cs b/Save/SaveValidator.cs deleted file mode 100644 index ed87161..0000000 --- a/Save/SaveValidator.cs +++ /dev/null @@ -1,56 +0,0 @@ -using MulderConfig.Configuration; - -namespace MulderConfig.Save; - -public static class SaveValidator -{ - public static bool IsValid(ConfigModel config, Dictionary save) - { - foreach (var entry in save) - { - var group = config.OptionGroups.FirstOrDefault(g => - g.Name.Equals(entry.Key, StringComparison.OrdinalIgnoreCase)); - - if (group == null) - return false; - - if (group.Type == "radioGroup") - { - if (entry.Value is not string selected) - return false; - - if (group.Radios == null) - return false; - - var exists = group.Radios.Any(r => - r.Value.Equals(selected, StringComparison.OrdinalIgnoreCase)); - - if (!exists) - return false; - } - else if (group.Type == "checkboxGroup") - { - if (group.Checkboxes == null) - return false; - - if (entry.Value is IEnumerable values && entry.Value is not string) - { - foreach (var value in values) - { - var exists = group.Checkboxes.Any(c => - c.Value.Equals(value, StringComparison.OrdinalIgnoreCase)); - - if (!exists) - return false; - } - } - else - { - return false; - } - } - } - - return true; - } -} diff --git a/SteamAddonHandler.cs b/SteamAddonHandler.cs deleted file mode 100644 index 0eb78c4..0000000 --- a/SteamAddonHandler.cs +++ /dev/null @@ -1,33 +0,0 @@ -using MulderConfig.Configuration; - -namespace MulderConfig; - -public sealed class SteamAddonHandler(ConfigModel config, string[] args) -{ - public int? ResolveAddonId() - { - for (int i = 0; i < args.Length - 1; i++) - { - if (args[i].Equals("-addon", StringComparison.OrdinalIgnoreCase) - && int.TryParse(args[i + 1], out int addonId)) - { - return addonId; - } - } - - return null; - } - - public string? ResolveAddonTitle(int? steamAddonId) - { - if (steamAddonId is null) - return null; - - var addons = config.Addons; - if (addons is null || addons.Count == 0) - return null; - - return addons.FirstOrDefault(a => a.SteamId == steamAddonId)?.Title - ?? addons[0].Title; - } -} diff --git a/UI/Form1.Designer.cs b/UI/Form1.Designer.cs deleted file mode 100644 index 66986ac..0000000 --- a/UI/Form1.Designer.cs +++ /dev/null @@ -1,96 +0,0 @@ -namespace MulderConfig.UI -{ - partial class Form1 - { - private System.ComponentModel.IContainer components = null; - - protected override void Dispose(bool disposing) - { - if (disposing && (components != null)) - { - components.Dispose(); - } - base.Dispose(disposing); - } - - #region Windows Form Designer generated code - - private void InitializeComponent() - { - comboBoxTitle = new ComboBox(); - panelOptions = new Panel(); - btnApply = new Button(); - btnSave = new Button(); - SuspendLayout(); - // - // comboBoxTitle - // - comboBoxTitle.BackColor = Color.FromArgb(89, 101, 119); - comboBoxTitle.Font = new Font("Segoe UI", 9F); - comboBoxTitle.ForeColor = SystemColors.HighlightText; - comboBoxTitle.FormattingEnabled = true; - comboBoxTitle.Location = new Point(12, 12); - comboBoxTitle.Name = "comboBoxTitle"; - comboBoxTitle.Size = new Size(418, 23); - comboBoxTitle.TabIndex = 0; - comboBoxTitle.SelectedIndexChanged += comboBoxTitle_SelectedIndexChanged; - // - // panelOptions - // - panelOptions.AutoSize = true; - panelOptions.BorderStyle = BorderStyle.FixedSingle; - panelOptions.ForeColor = SystemColors.Control; - panelOptions.Location = new Point(12, 53); - panelOptions.Name = "panelOptions"; - panelOptions.Padding = new Padding(20); - panelOptions.Size = new Size(600, 60); - panelOptions.TabIndex = 1; - // - // btnApply - // - btnApply.Location = new Point(537, 12); - btnApply.Name = "btnApply"; - btnApply.Size = new Size(75, 23); - btnApply.TabIndex = 2; - btnApply.Text = "Apply"; - btnApply.UseVisualStyleBackColor = true; - btnApply.Click += btnApply_Click; - // - // btnSave - // - btnSave.Location = new Point(436, 12); - btnSave.Name = "btnSave"; - btnSave.Size = new Size(95, 23); - btnSave.TabIndex = 3; - btnSave.Text = "Save Config"; - btnSave.UseVisualStyleBackColor = true; - btnSave.Click += btnSave_Click; - // - // Form1 - // - AutoScaleDimensions = new SizeF(7F, 15F); - AutoScaleMode = AutoScaleMode.Font; - AutoSize = true; - BackColor = Color.FromArgb(35, 35, 45); - ClientSize = new Size(624, 341); - Controls.Add(btnSave); - Controls.Add(btnApply); - Controls.Add(panelOptions); - Controls.Add(comboBoxTitle); - Name = "Form1"; - Padding = new Padding(0, 0, 0, 20); - StartPosition = FormStartPosition.CenterScreen; - Text = "Form1"; - Load += Form1_Load; - ResumeLayout(false); - PerformLayout(); - } - - #endregion - - private ComboBox comboBoxTitle; - private Panel panelOptions; - private Button btnApply; - private Button btnSave; - } -} diff --git a/UI/Form1.cs b/UI/Form1.cs deleted file mode 100644 index 00f0b55..0000000 --- a/UI/Form1.cs +++ /dev/null @@ -1,98 +0,0 @@ -using MulderConfig.Save; -using MulderConfig.Configuration; -using MulderConfig.Apply; - -namespace MulderConfig.UI -{ - public partial class Form1 : Form - { - private readonly string _title; - private readonly ConfigModel _config; - private readonly ApplyManager _applyManager; - private readonly FormBuilder _formBuilder; - private readonly FormValidator _formValidator; - private readonly FormSelectionProvider _formSelectionProvider; - private readonly FormController _formController; - private readonly SaveLoader _saveLoader; - private readonly SaveSaver _saveSaver; - private bool _isInitializing; - - public Form1( - string title, - ConfigModel config, - ApplyManager applyManager, - FormBuilder formBuilder, - FormValidator formValidator, - FormSelectionProvider formSelectionProvider, - SaveLoader saveLoader, - SaveSaver saveSaver) - { - _title = title; - _config = config; - _applyManager = applyManager; - _formBuilder = formBuilder; - _formValidator = formValidator; - _formSelectionProvider = formSelectionProvider; - _saveLoader = saveLoader; - _saveSaver = saveSaver; - - InitializeComponent(); - - _formController = new FormController(_formSelectionProvider, _formValidator, btnApply, btnSave); - } - - private void Form1_Load(object sender, EventArgs e) - { - _isInitializing = true; - - Text = _config.Game.Title; - _formBuilder.BuildComboBox(_config, comboBoxTitle); - - var initialIndex = comboBoxTitle.Items.IndexOf(_title); - if (initialIndex >= 0) - { - comboBoxTitle.SelectedIndex = initialIndex; - } - - _formSelectionProvider.SetTitle(comboBoxTitle.SelectedItem?.ToString() ?? "default"); - - _formBuilder.BuildForm(_config, panelOptions, _formController.UpdateButtons); - _formController.LoadSavedChoices(_saveLoader); - - _isInitializing = false; - } - - private void comboBoxTitle_SelectedIndexChanged(object sender, EventArgs e) - { - if (_isInitializing) - return; - - _formSelectionProvider.SetTitle(comboBoxTitle.SelectedItem?.ToString() ?? "default"); - _formController.LoadSavedChoices(_saveLoader); - } - - private void btnApply_Click(object sender, EventArgs e) - { - if (!_formValidator.IsValid()) - { - MessageBox.Show("Form is invalid", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); - return; - } - - _applyManager.Apply(_formSelectionProvider); - MessageBox.Show("Done.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); - } - - private void btnSave_Click(object sender, EventArgs e) - { - if (!_formValidator.IsValid()) - { - MessageBox.Show("Form is invalid", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); - return; - } - - _saveSaver.SaveChoices(_formSelectionProvider.GetTitle(), _formSelectionProvider.GetChoices()); - MessageBox.Show($"Configuration saved for {_formSelectionProvider.GetTitle()}", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); - } - } -} diff --git a/UI/Form1.resx b/UI/Form1.resx deleted file mode 100644 index 4edf53f..0000000 --- a/UI/Form1.resx +++ /dev/null @@ -1,120 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - \ No newline at end of file diff --git a/UI/FormBuilder.cs b/UI/FormBuilder.cs deleted file mode 100644 index ef164f7..0000000 --- a/UI/FormBuilder.cs +++ /dev/null @@ -1,96 +0,0 @@ -using MulderConfig.Configuration; - -namespace MulderConfig.UI; - -public class FormBuilder(FormValidator formValidator, FormSelectionProvider formSelectionProvider) -{ - public void BuildComboBox(ConfigModel config, ComboBox comboBox) - { - comboBox.Items.Clear(); - comboBox.Items.Add(config.Game.Title); - - if (config.Addons == null || config.Addons.Count == 0) { - comboBox.Enabled = false; - return; - } - - foreach (var addon in config.Addons) - { - comboBox.Items.Add(addon.Title); - } - } - - public void BuildForm(ConfigModel config, Panel panelOptions, Action updateButtons) - { - panelOptions.Controls.Clear(); - - int y = 10; - - foreach (var group in config.OptionGroups) - { - var groupBox = new GroupBox - { - Text = group.Name, - Left = 10, - Top = y, - Width = panelOptions.ClientSize.Width - 40, - Height = 10, - AutoSize = true, - ForeColor = Color.White, - }; - - panelOptions.Controls.Add(groupBox); - - int innerY = 20; - - if (group.Type == "radioGroup" && group.Radios != null) - { - foreach (var radioChoice in group.Radios) - { - var radioButton = new RadioButton - { - Text = radioChoice.Value, - Left = 10, - Top = innerY, - AutoSize = true - }; - - radioButton.CheckedChanged += (s, e) => - { - formValidator.ApplyWhenConstraints(); - updateButtons(); - }; - groupBox.Controls.Add(radioButton); - formSelectionProvider.AddRadioButton(group.Name, radioButton, radioChoice.Value); - innerY += 25; - } - } - else if (group.Type == "checkboxGroup" && group.Checkboxes != null) - { - foreach (var checkItem in group.Checkboxes) - { - var checkBox = new CheckBox - { - Text = checkItem.Value, - Left = 10, - Top = innerY, - AutoSize = true, - Tag = checkItem.Value - }; - - checkBox.CheckedChanged += (s, e) => - { - formValidator.ApplyWhenConstraints(); - updateButtons(); - }; - groupBox.Controls.Add(checkBox); - formSelectionProvider.AddCheckBox(checkBox, checkItem.Value); - innerY += 25; - } - } - - groupBox.Height = innerY + 10; - y += groupBox.Height + 8; - } - } -} diff --git a/UI/FormController.cs b/UI/FormController.cs deleted file mode 100644 index 8a264da..0000000 --- a/UI/FormController.cs +++ /dev/null @@ -1,73 +0,0 @@ -using MulderConfig.Save; - -namespace MulderConfig.UI; - -public sealed class FormController( - FormSelectionProvider selectionProvider, - FormValidator validator, - Button btnApply, - Button btnSave) -{ - public void LoadSavedChoices(SaveLoader saveLoader) - { - var saved = saveLoader.Load(selectionProvider.GetTitle()); - ResetChoices(); - ApplyChoices(saved); - - validator.ApplyWhenConstraints(); - UpdateButtons(); - } - - private void ResetChoices() - { - foreach (var grp in selectionProvider.RadioButtons) - { - foreach (var rb in grp.Value.Values) - { - rb.Enabled = true; - rb.Checked = false; - } - } - - foreach (var cb in selectionProvider.CheckBoxes.Values) - { - cb.Enabled = true; - cb.Checked = false; - } - } - - private void ApplyChoices(Dictionary savedChoices) - { - foreach (var entry in savedChoices) - { - if (entry.Value is IEnumerable values && entry.Value is not string) - { - foreach (var value in values) - { - if (selectionProvider.CheckBoxes.TryGetValue(value, out var cb)) - { - cb.Checked = true; - } - } - - continue; - } - - if (entry.Value is string selected) - { - var groupName = entry.Key; - if (selectionProvider.RadioButtons.TryGetValue(groupName, out var radios) && radios.TryGetValue(selected, out var rb)) - { - rb.Checked = true; - } - } - } - } - - public void UpdateButtons() - { - var isValid = validator.IsValid(); - btnApply.Enabled = isValid; - btnSave.Enabled = isValid; - } -} diff --git a/UI/FormSelectionProvider.cs b/UI/FormSelectionProvider.cs deleted file mode 100644 index ea8e79c..0000000 --- a/UI/FormSelectionProvider.cs +++ /dev/null @@ -1,59 +0,0 @@ -using MulderConfig.Configuration; - -namespace MulderConfig.UI; - -public class FormSelectionProvider(ConfigModel config) : ISelectionProvider -{ - private string title = "default"; - public readonly Dictionary> RadioButtons = []; - public readonly Dictionary CheckBoxes = []; - - internal void AddRadioButton(string groupName, RadioButton radioButton, string value) - { - if (!RadioButtons.ContainsKey(groupName)) - RadioButtons[groupName] = []; - - RadioButtons[groupName][value] = radioButton; - } - - internal void AddCheckBox(CheckBox checkBox, string value) - { - CheckBoxes[value] = checkBox; - } - - public void SetTitle(string title) - { - this.title = title; - } - - public string GetTitle() - { - return title; - } - - public Dictionary GetChoices() - { - var choices = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var group in config.OptionGroups) - { - if (group.Type == "radioGroup" && RadioButtons.TryGetValue(group.Name, out var radios)) - { - var selectedRadio = radios.Values.FirstOrDefault(radio => radio.Checked); - if (selectedRadio != null) - choices[group.Name] = selectedRadio.Text; - } - else if (group.Type == "checkboxGroup" && group.Checkboxes != null) - { - var selectedCheckboxes = group.Checkboxes - .Where(checkbox => CheckBoxes.TryGetValue(checkbox.Value, out var checkbox2) && checkbox2.Checked) - .Select(checkbox => checkbox.Value) - .ToList(); - - choices[group.Name] = selectedCheckboxes; - } - } - - return choices; - } -} diff --git a/UI/FormValidator.cs b/UI/FormValidator.cs deleted file mode 100644 index 11d58c2..0000000 --- a/UI/FormValidator.cs +++ /dev/null @@ -1,72 +0,0 @@ -using MulderConfig.Logic; -using MulderConfig.Configuration; - -namespace MulderConfig.UI -{ - public class FormValidator(ConfigModel config, FormSelectionProvider formSelectionProvider) - { - public bool IsValid() - { - if (config.OptionGroups == null) - return false; - - foreach (var group in config.OptionGroups) - { - if (group.Type != "radioGroup") - continue; - - if (!formSelectionProvider.RadioButtons.TryGetValue(group.Name, out var radios)) - continue; - - if (!radios.Values.Any(rb => rb.Enabled && rb.Checked)) - return false; - } - - return true; - } - - public void ApplyWhenConstraints() - { - var selected = formSelectionProvider.GetChoices(); - selected["Title"] = formSelectionProvider.GetTitle(); - - foreach (var group in config.OptionGroups) - { - if (group.Type == "radioGroup" - && group.Radios != null - && formSelectionProvider.RadioButtons.TryGetValue(group.Name, out var radios)) - { - foreach (var radioRow in group.Radios) - { - if (radioRow.DisabledWhen == null) - continue; - - if (radios.TryGetValue(radioRow.Value, out var radioButton)) - { - var disable = WhenResolver.Match(radioRow.DisabledWhen, selected); - radioButton.Enabled = !disable; - if (disable) - radioButton.Checked = false; - } - } - } - else if (group.Type == "checkboxGroup" && group.Checkboxes != null) - { - foreach (var checkboxRow in group.Checkboxes) - { - if (checkboxRow.DisabledWhen == null) - continue; - - if (formSelectionProvider.CheckBoxes.TryGetValue(checkboxRow.Value, out var checkBox)) - { - var disable = WhenResolver.Match(checkboxRow.DisabledWhen, selected); - checkBox.Enabled = !disable; - if (disable) - checkBox.Checked = false; - } - } - } - } - } - } -} diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..caacf86 --- /dev/null +++ b/build.rs @@ -0,0 +1,26 @@ +fn main() { + println!("cargo:rerun-if-env-changed=APP_VERSION"); + let version = + std::env::var("APP_VERSION").unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_string()); + println!("cargo:rustc-env=APP_VERSION={version}"); + + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + let mut res = winres::WindowsResource::new(); + res.set_icon("favicon.ico"); + res.set("FileDescription", "MulderConfig"); + res.set("FileVersion", &format!("{version}.0")); + res.set("ProductName", "MulderConfig"); + res.set("ProductVersion", &version); + + // Pack version into 4×u16 quad for VS_FIXEDFILEINFO binary fields + let quad = version + .splitn(4, '.') + .map(|p| p.parse::().unwrap_or(0)) + .zip([48u64, 32, 16, 0]) + .fold(0, |acc, (v, shift)| acc | (v << shift)); + res.set_version_info(winres::VersionInfo::FILEVERSION, quad); + res.set_version_info(winres::VersionInfo::PRODUCTVERSION, quad); + + res.compile().expect("Failed to compile Windows resources"); + } +} diff --git a/src/addon_detector.rs b/src/addon_detector.rs new file mode 100644 index 0000000..e0d615c --- /dev/null +++ b/src/addon_detector.rs @@ -0,0 +1,64 @@ +use crate::config::model::Addon; + +/// Extracts the numeric Steam ID from a `-addon ` flag in the given args. +/// Returns `None` if the flag is absent or the value is not a valid u32. +pub fn parse_addon_arg(args: &[String]) -> Option { + args.windows(2) + .find(|w| w[0] == "-addon") + .and_then(|w| w[1].parse::().ok()) +} + +/// Looks up the addon title for the given Steam ID. +/// Returns `Ok(title)` if found, `Err(message)` if the ID is unknown. +pub fn resolve_addon(addons: &[Addon], steam_id: u32) -> Result { + addons + .iter() + .find(|a| a.steam_id == steam_id) + .map(|a| a.title.clone()) + .ok_or_else(|| { + format!("Unknown addon Steam ID {steam_id}, launching with base game configuration.") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::model::Addon; + + fn addon(steam_id: u32, title: &str) -> Addon { + Addon { + steam_id, + title: title.into(), + } + } + + #[test] + fn resolve_addon_returns_title_when_found() { + let addons = vec![addon(272270, "Duke Caribbean")]; + assert_eq!(resolve_addon(&addons, 272270), Ok("Duke Caribbean".into())); + } + + #[test] + fn resolve_addon_returns_err_when_unknown() { + let addons = vec![addon(272270, "Duke Caribbean")]; + assert!(resolve_addon(&addons, 99999).is_err()); + } + + #[test] + fn parse_addon_arg_returns_none_when_absent() { + let args = vec!["MulderConfig.exe".into()]; + assert_eq!(parse_addon_arg(&args), None); + } + + #[test] + fn parse_addon_arg_returns_steam_id() { + let args = vec!["MulderConfig.exe".into(), "-addon".into(), "272270".into()]; + assert_eq!(parse_addon_arg(&args), Some(272270)); + } + + #[test] + fn parse_addon_arg_returns_none_for_non_numeric_value() { + let args = vec!["MulderConfig.exe".into(), "-addon".into(), "abc".into()]; + assert_eq!(parse_addon_arg(&args), None); + } +} diff --git a/src/apply.rs b/src/apply.rs new file mode 100644 index 0000000..698abcc --- /dev/null +++ b/src/apply.rs @@ -0,0 +1,48 @@ +pub mod exe_replacer; +pub mod file_ops; +pub mod launcher; + +use crate::config::when_resolver::{SelectionValue, Selections}; +use crate::context::AppContext; +use crate::save::SaveValue; + +/// Converts the save-format selections (+ injects "Title") into WhenResolver selections. +fn to_selections(ctx: &AppContext) -> Selections { + let mut sel = Selections::new(); + sel.insert( + "Title".to_string(), + SelectionValue::Single(ctx.active_title.clone()), + ); + for (k, v) in &ctx.selections { + let sv = match v { + SaveValue::Single(s) => SelectionValue::Single(s.clone()), + SaveValue::Multiple(v) => SelectionValue::Multiple(v.clone()), + }; + sel.insert(k.clone(), sv); + } + sel +} + +/// Runs file operations and (if needed) replaces the exe. +/// Returns a list of error messages (empty = success). +/// Called by both the Apply button and the `-apply` CLI argument. +pub fn apply(ctx: &AppContext) -> Vec { + let selected = to_selections(ctx); + let mut errors = file_ops::execute_operations(&ctx.config.actions.operations, &selected); + if !ctx.config.actions.launch.is_empty() + && !exe_replacer::is_replaced(&ctx.config) + && let Err(e) = exe_replacer::replace(&ctx.config) + { + errors.push(e); + } + errors +} + +/// Resolves launch rules and starts the game process. +/// Called by `Mode::Launch` (when Steam launches the replaced exe). +pub fn launch(ctx: &AppContext) { + let selected = to_selections(ctx); + if let Err(e) = launcher::launch(&ctx.config, &selected) { + crate::ui::error::warn(&e); + } +} diff --git a/src/apply/exe_replacer.rs b/src/apply/exe_replacer.rs new file mode 100644 index 0000000..0d977a7 --- /dev/null +++ b/src/apply/exe_replacer.rs @@ -0,0 +1,213 @@ +use crate::config::model::ConfigModel; +use std::os::windows::io::AsRawHandle; +use std::path::{Path, PathBuf}; +use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, +}; + +/// Returns true if the file contains the UTF-16 string "MulderConfig" in its PE resources. +fn is_mulder_config(path: &Path) -> bool { + let Ok(bytes) = std::fs::read(path) else { + return false; + }; + let needle: Vec = "MulderConfig" + .encode_utf16() + .flat_map(|c| c.to_le_bytes()) + .collect(); + bytes.windows(needle.len()).any(|w| w == needle.as_slice()) +} + +pub struct ExePaths { + pub original: PathBuf, // Game.exe (becomes a hard link after apply) + pub original_backup: PathBuf, // Game_o.exe (original game exe, preserved) + pub mulder_config: PathBuf, // This exe (MulderConfig.exe or renamed) +} + +/// Pure path computation — separated from env access for testability. +fn build_paths(base: &Path, original_exe: &str) -> (PathBuf, PathBuf) { + let stem = Path::new(original_exe) + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let ext = Path::new(original_exe) + .extension() + .map(|e| format!(".{}", e.to_string_lossy())) + .unwrap_or_default(); + (base.join(original_exe), base.join(format!("{stem}_o{ext}"))) +} + +pub fn get_paths(config: &ConfigModel) -> ExePaths { + let base = exe_dir(); + let (original, backup) = build_paths(&base, &config.game.original_exe); + ExePaths { + original, + original_backup: backup, + mulder_config: std::env::current_exe().unwrap(), + } +} + +/// Returns true when Game.exe is a hard link to this exe. +pub fn is_replaced(config: &ConfigModel) -> bool { + let paths = get_paths(config); + paths.original.exists() && same_file(&paths.original, &paths.mulder_config).unwrap_or(false) +} + +fn same_file(a: &Path, b: &Path) -> std::io::Result { + let fa = std::fs::File::open(a)?; + let fb = std::fs::File::open(b)?; + unsafe { + let mut ia: BY_HANDLE_FILE_INFORMATION = std::mem::zeroed(); + let mut ib: BY_HANDLE_FILE_INFORMATION = std::mem::zeroed(); + if GetFileInformationByHandle(fa.as_raw_handle() as _, &mut ia) == 0 + || GetFileInformationByHandle(fb.as_raw_handle() as _, &mut ib) == 0 + { + return Err(std::io::Error::last_os_error()); + } + Ok(ia.dwVolumeSerialNumber == ib.dwVolumeSerialNumber + && ia.nFileIndexHigh == ib.nFileIndexHigh + && ia.nFileIndexLow == ib.nFileIndexLow) + } +} + +/// Replaces Game.exe with a hard link to this exe. +/// Original Game.exe is renamed to Game_o.exe. +/// If a stale backup already exists (e.g. after a launcher update), it is removed first. +/// If Game.exe is itself a MulderConfig build (legacy C# migration), it is deleted instead +/// of being preserved as backup, to avoid overwriting the real Game_o.exe. +pub fn replace(config: &ConfigModel) -> Result<(), String> { + let paths = get_paths(config); + if !paths.original.exists() { + return Err(format!( + "Original exe not found: {}", + paths.original.display() + )); + } + + if is_mulder_config(&paths.original) { + // original is a legacy MulderConfig — discard it, keep the existing backup + std::fs::remove_file(&paths.original).map_err(|e| { + format!( + "Failed to remove legacy MulderConfig '{}': {e}", + paths.original.display() + ) + })?; + } else { + if paths.original_backup.exists() { + std::fs::remove_file(&paths.original_backup).map_err(|e| { + format!( + "Failed to remove stale backup '{}': {e}", + paths.original_backup.display() + ) + })?; + } + std::fs::rename(&paths.original, &paths.original_backup).map_err(|e| { + format!( + "Failed to rename '{}' to '{}': {e}", + paths.original.display(), + paths.original_backup.display() + ) + })?; + } + + std::fs::hard_link(&paths.mulder_config, &paths.original).map_err(|e| { + let _ = std::fs::rename(&paths.original_backup, &paths.original); + format!("Failed to create hard link: {e}") + })?; + Ok(()) +} + +/// Returns the actual game exe path to launch. +/// After replacement: Game_o.exe (original game); otherwise: Game.exe. +pub fn get_default_launch_exe(config: &ConfigModel) -> PathBuf { + let paths = get_paths(config); + if paths.original_backup.exists() { + paths.original_backup + } else { + paths.original + } +} + +fn exe_dir() -> PathBuf { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())) + .unwrap_or_else(|| PathBuf::from(".")) +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- Pure path tests --- + + #[test] + fn build_paths_simple() { + let (original, backup) = build_paths(Path::new("C:/game"), "Game.exe"); + assert_eq!(original, Path::new("C:/game/Game.exe")); + assert_eq!(backup, Path::new("C:/game/Game_o.exe")); + } + + #[test] + fn build_paths_preserves_extension() { + let (_, backup) = build_paths(Path::new("."), "Launcher.exe"); + assert_eq!(backup.file_name().unwrap(), "Launcher_o.exe"); + } + + #[test] + fn build_paths_no_extension() { + let (_, backup) = build_paths(Path::new("."), "game"); + assert_eq!(backup.file_name().unwrap(), "game_o"); + } + + // --- Filesystem tests --- + + fn setup_dir(name: &str) -> PathBuf { + // Must be on the same drive as current_exe for rename + hard_link to work. + let dir = exe_dir().join(name); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn replace_renames_original_and_creates_hard_link() { + let dir = setup_dir("mulder_replace_basic"); + let original = dir.join("Game.exe"); + let backup = dir.join("Game_o.exe"); + let mulder_config = std::env::current_exe().unwrap(); + + std::fs::write(&original, b"fake game").unwrap(); + let _ = std::fs::remove_file(&backup); + + std::fs::rename(&original, &backup).unwrap(); + std::fs::hard_link(&mulder_config, &original).unwrap(); + + assert!(backup.exists(), "backup should exist"); + assert!(original.exists(), "hard link should exist"); + + let _ = std::fs::remove_file(&original); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn replace_overwrites_stale_backup() { + let dir = setup_dir("mulder_replace_stale"); + let original = dir.join("Game.exe"); + let backup = dir.join("Game_o.exe"); + let mulder_config = std::env::current_exe().unwrap(); + + std::fs::write(&original, b"new game v2").unwrap(); + std::fs::write(&backup, b"old game v1").unwrap(); + + // Simulate the stale-backup removal + rename sequence from replace() + std::fs::remove_file(&backup).unwrap(); + std::fs::rename(&original, &backup).unwrap(); + std::fs::hard_link(&mulder_config, &original).unwrap(); + + let content = std::fs::read(&backup).unwrap(); + assert_eq!(content, b"new game v2", "backup must be the new version"); + + let _ = std::fs::remove_file(&original); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/apply/file_ops.rs b/src/apply/file_ops.rs new file mode 100644 index 0000000..2575d93 --- /dev/null +++ b/src/apply/file_ops.rs @@ -0,0 +1,615 @@ +use std::path::{Path, PathBuf}; + +use crate::config::model::OperationAction; +use crate::config::when_resolver::{Selections, match_when}; +use regex_lite::Regex; + +pub fn execute_operations(operations: &[OperationAction], selected: &Selections) -> Vec { + let mut errors = Vec::new(); + for action in operations { + if let Some(when) = &action.when + && !match_when(when, selected) + { + continue; + } + let result = match action.operation.to_lowercase().as_str() { + "setreadonly" => exec_set_readonly(action, true), + "removereadonly" => exec_set_readonly(action, false), + "rename" | "move" => exec_move(action), + "copy" => exec_copy(action), + "delete" => exec_delete(action), + "replaceline" => exec_replace_line(action), + "removeline" => exec_remove_line(action), + "replacetext" => exec_replace_text(action), + other => Err(format!("Unknown operation: {other}")), + }; + if let Err(e) = result { + errors.push(format!("Operation '{}' failed:\n{e}", action.operation)); + } + } + errors +} + +// ── Path helpers ───────────────────────────────────────────────────────────── + +fn exe_dir() -> PathBuf { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())) + .unwrap_or_else(|| PathBuf::from(".")) +} + +/// Expands %VAR% style environment variables and resolves the path. +/// Relative paths are resolved against the exe directory. +fn resolve_path(path: &str) -> PathBuf { + let expanded = expand_env_vars(path); + let p = Path::new(&expanded); + if p.is_absolute() { + p.to_path_buf() + } else { + exe_dir().join(p) + } +} + +pub fn expand_env_vars(s: &str) -> String { + let mut result = String::new(); + let mut rest = s; + while let Some(start) = rest.find('%') { + result.push_str(&rest[..start]); + rest = &rest[start + 1..]; + if let Some(end) = rest.find('%') { + let var_name = &rest[..end]; + match std::env::var(var_name) { + Ok(val) => result.push_str(&val), + Err(_) => { + result.push('%'); + result.push_str(var_name); + result.push('%'); + } + } + rest = &rest[end + 1..]; + } else { + result.push('%'); + } + } + result.push_str(rest); + result +} + +fn resolve_files(files: &Option>) -> Vec { + let Some(list) = files else { + return Vec::new(); + }; + let mut out = Vec::new(); + for f in list { + if f.is_empty() { + continue; + } + let p = resolve_path(f); + if p.exists() { + out.push(p); + } else { + crate::ui::error::warn(&format!("File not found: {}", p.display())); + } + } + out +} + +// ── Operations ──────────────────────────────────────────────────────────────── + +fn exec_set_readonly(action: &OperationAction, read_only: bool) -> Result<(), String> { + let files = action + .files + .as_ref() + .filter(|v| !v.is_empty()) + .ok_or("Missing 'files' for setReadOnly/removeReadOnly.")?; + for f in files { + if f.is_empty() { + continue; + } + let p = resolve_path(f); + if !p.exists() { + continue; + } // idempotent + let meta = std::fs::metadata(&p).map_err(|e| format!("{}: {e}", p.display()))?; + let mut perms = meta.permissions(); + perms.set_readonly(read_only); + std::fs::set_permissions(&p, perms).map_err(|e| format!("{}: {e}", p.display()))?; + } + Ok(()) +} + +fn exec_move(action: &OperationAction) -> Result<(), String> { + let src = action + .source + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or("Missing 'source' for rename/move.")?; + let dst = action + .target + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or("Missing 'target' for rename/move.")?; + let src = resolve_path(src); + let dst = resolve_path(dst); + if !src.exists() { + return Ok(()); + } // idempotent + if !same_drive(&src, &dst) { + return Err(format!( + "Cannot move '{}' to '{}': source and destination are on different drives.", + src.display(), + dst.display() + )); + } + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("{e}"))?; + } + // Remove existing destination + if dst.is_file() { + std::fs::remove_file(&dst).map_err(|e| format!("{e}"))?; + } else if dst.is_dir() { + std::fs::remove_dir_all(&dst).map_err(|e| format!("{e}"))?; + } + std::fs::rename(&src, &dst).map_err(|e| format!("{e}")) +} + +fn same_drive(a: &Path, b: &Path) -> bool { + a.components().next() == b.components().next() +} + +fn exec_copy(action: &OperationAction) -> Result<(), String> { + let src = action + .source + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or("Missing 'source' for copy.")?; + let dst = action + .target + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or("Missing 'target' for copy.")?; + let src = resolve_path(src); + let dst = resolve_path(dst); + if !src.exists() { + return Ok(()); + } // idempotent + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("{e}"))?; + } + std::fs::copy(&src, &dst) + .map(|_| ()) + .map_err(|e| format!("{e}")) +} + +fn exec_delete(action: &OperationAction) -> Result<(), String> { + let src = action + .source + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or("Missing 'source' for delete.")?; + let p = resolve_path(src); + if p.exists() { + std::fs::remove_file(&p).map_err(|e| format!("{e}"))?; + } + Ok(()) +} + +fn exec_replace_line(action: &OperationAction) -> Result<(), String> { + let pattern = action + .pattern + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or("Missing 'pattern' for replaceLine.")?; + let replacement = action + .replacement + .as_deref() + .ok_or("Missing 'replacement' for replaceLine.")?; + let re = Regex::new(&format!("(?i){pattern}")) + .map_err(|e| format!("Invalid regex pattern '{pattern}': {e}"))?; + let replacement_str = unescape(replacement); + let replacement_lines: Vec<&str> = replacement_str + .split('\n') + .filter(|l| !l.is_empty()) + .collect(); + for path in resolve_files(&action.files) { + filter_lines_in_file(&path, &re, &replacement_lines) + .map_err(|e| format!("{}: {e}", path.display()))?; + } + Ok(()) +} + +fn exec_remove_line(action: &OperationAction) -> Result<(), String> { + let pattern = action + .pattern + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or("Missing 'pattern' for removeLine.")?; + let re = Regex::new(&format!("(?i){pattern}")) + .map_err(|e| format!("Invalid regex pattern '{pattern}': {e}"))?; + for path in resolve_files(&action.files) { + filter_lines_in_file(&path, &re, &[]).map_err(|e| format!("{}: {e}", path.display()))?; + } + Ok(()) +} + +fn exec_replace_text(action: &OperationAction) -> Result<(), String> { + let search = action + .search + .as_deref() + .ok_or("Missing 'search' for replaceText.")?; + let replacement = action + .replacement + .as_deref() + .ok_or("Missing 'replacement' for replaceText.")?; + for path in resolve_files(&action.files) { + let content = + std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; + let new_content = content.replace(search, replacement); + if new_content != content { + std::fs::write(&path, new_content).map_err(|e| format!("{}: {e}", path.display()))?; + } + } + Ok(()) +} + +// ── File helpers ────────────────────────────────────────────────────────────── + +fn filter_lines_in_file(path: &Path, re: &Regex, replacement_lines: &[&str]) -> Result<(), String> { + let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?; + + // Detect the dominant line ending so we can preserve it. + let eol = if content.contains("\r\n") { + "\r\n" + } else { + "\n" + }; + + // split('\n') on a file ending with '\n' always produces a trailing "". + // We peel that off, process the real lines, then re-add the trailing EOL + // at the very end based on whether the original file had one. + let had_trailing_newline = content.ends_with('\n'); + let all_lines: Vec<&str> = content.split('\n').collect(); + let raw_lines = if had_trailing_newline && !all_lines.is_empty() { + &all_lines[..all_lines.len() - 1] + } else { + &all_lines[..] + }; + + let mut modified = false; + let mut out_lines: Vec<&str> = Vec::with_capacity(raw_lines.len()); + + for raw_line in raw_lines { + let line = raw_line.strip_suffix('\r').unwrap_or(raw_line); + if re.is_match(line) { + modified = true; + out_lines.extend_from_slice(replacement_lines); + } else { + out_lines.push(line); + } + } + + if modified { + let mut out = out_lines.join(eol); + if had_trailing_newline { + out.push_str(eol); + } + std::fs::write(path, out.as_bytes()).map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some('n') => out.push('\n'), + Some('t') => out.push('\t'), + Some('r') => out.push('\r'), + Some(other) => { + out.push('\\'); + out.push(other); + } + None => out.push('\\'), + } + } else { + out.push(c); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::model::OperationAction; + use crate::config::when_resolver::Selections; + + fn op_replace_line(files: &[&str], pattern: &str, replacement: &str) -> OperationAction { + OperationAction { + when: None, + operation: "replaceLine".into(), + source: None, + target: None, + files: Some(files.iter().map(|s| (*s).into()).collect()), + pattern: Some(pattern.into()), + search: None, + replacement: Some(replacement.into()), + } + } + + fn op_copy(source: &str, target: &str) -> OperationAction { + OperationAction { + when: None, + operation: "copy".into(), + source: Some(source.into()), + target: Some(target.into()), + files: None, + pattern: None, + search: None, + replacement: None, + } + } + + fn op_move(source: &str, target: &str) -> OperationAction { + OperationAction { + when: None, + operation: "move".into(), + source: Some(source.into()), + target: Some(target.into()), + files: None, + pattern: None, + search: None, + replacement: None, + } + } + + #[test] + fn move_directory_moves_contents() { + let root = std::env::temp_dir().join(format!("mulderconfig_test_{}", std::process::id())); + let source_dir = root.join("srcDir"); + let target_dir = root.join("dstDir"); + std::fs::create_dir_all(&source_dir).unwrap(); + std::fs::write(source_dir.join("a.txt"), "hello").unwrap(); + + let errors = execute_operations( + &[op_move( + source_dir.to_str().unwrap(), + target_dir.to_str().unwrap(), + )], + &Selections::new(), + ); + + assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors); + assert!(!source_dir.exists(), "Source should not exist after move"); + assert!(target_dir.exists(), "Target should exist after move"); + assert!( + target_dir.join("a.txt").exists(), + "File should be in target" + ); + + std::fs::remove_dir_all(&root).ok(); + } + + // ── exec_copy ───────────────────────────────────────────────────────────── + + #[test] + fn copy_creates_missing_parent_dir() { + let root = + std::env::temp_dir().join(format!("mulderconfig_copy_test_{}", std::process::id())); + let src_file = root.join("src.txt"); + let dst_file = root.join("nested").join("subdir").join("dst.txt"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(&src_file, b"hello").unwrap(); + + let errors = execute_operations( + &[op_copy( + src_file.to_str().unwrap(), + dst_file.to_str().unwrap(), + )], + &Selections::new(), + ); + + assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors); + assert!(dst_file.exists(), "Copied file should exist at destination"); + assert_eq!(std::fs::read(&dst_file).unwrap(), b"hello"); + + std::fs::remove_dir_all(&root).ok(); + } + + // ── same_drive ──────────────────────────────────────────────────────────── + + #[test] + fn same_drive_identical_root() { + assert!(same_drive( + Path::new("C:\\foo\\a.txt"), + Path::new("C:\\bar\\b.txt") + )); + } + + #[test] + fn same_drive_different_root() { + assert!(!same_drive(Path::new("C:\\foo"), Path::new("D:\\bar"))); + } + + #[test] + fn same_drive_case_insensitive_roots() { + // On Windows path components are case-insensitive by convention; + // this test documents the current behaviour (Rust compares raw bytes). + // In practice config paths always use consistent casing. + assert!(same_drive(Path::new("C:\\foo"), Path::new("C:\\bar"))); + } + + // ── unescape ────────────────────────────────────────────────────────────── + + #[test] + fn unescape_newline() { + assert_eq!(unescape("a\\nb"), "a\nb"); + } + + #[test] + fn unescape_tab() { + assert_eq!(unescape("a\\tb"), "a\tb"); + } + + #[test] + fn unescape_unknown_keeps_backslash() { + assert_eq!(unescape("a\\xb"), "a\\xb"); + } + + #[test] + fn unescape_trailing_backslash() { + assert_eq!(unescape("a\\"), "a\\"); + } + + // ── expand_env_vars ─────────────────────────────────────────────────────── + + #[test] + fn expand_env_vars_known() { + // PATH is always set, so we use it to test expansion without set_var. + let path_val = std::env::var("PATH").unwrap(); + assert_eq!(expand_env_vars("%PATH%"), path_val); + } + + #[test] + fn expand_env_vars_unknown_keeps_literal() { + assert_eq!(expand_env_vars("%NO_SUCH_VAR_XYZ%"), "%NO_SUCH_VAR_XYZ%"); + } + + #[test] + fn expand_env_vars_no_percent() { + assert_eq!(expand_env_vars("just a string"), "just a string"); + } + + // ── filter_lines_in_file ────────────────────────────────────────────────── + + fn write_temp(name: &str, content: &[u8]) -> std::path::PathBuf { + let path = std::env::temp_dir().join(name); + std::fs::write(&path, content).unwrap(); + path + } + + #[test] + fn filter_removes_matching_line() { + let path = write_temp("mulder_remove.txt", b"keep\nremove me\nalso keep\n"); + let re = Regex::new("(?i)remove").unwrap(); + filter_lines_in_file(&path, &re, &[]).unwrap(); + let result = std::fs::read_to_string(&path).unwrap(); + assert_eq!(result, "keep\nalso keep\n"); + } + + #[test] + fn filter_replaces_matching_line() { + let path = write_temp("mulder_replace.txt", b"before\nold line\nafter\n"); + let re = Regex::new("(?i)old").unwrap(); + filter_lines_in_file(&path, &re, &["new line"]).unwrap(); + let result = std::fs::read_to_string(&path).unwrap(); + assert_eq!(result, "before\nnew line\nafter\n"); + } + + #[test] + fn filter_is_case_insensitive() { + let path = write_temp("mulder_case.txt", b"KEEP\nOLD LINE\nKEEP\n"); + let re = Regex::new("(?i)old line").unwrap(); + filter_lines_in_file(&path, &re, &[]).unwrap(); + let result = std::fs::read_to_string(&path).unwrap(); + assert_eq!(result, "KEEP\nKEEP\n"); + } + + #[test] + fn filter_preserves_crlf() { + let path = write_temp("mulder_crlf.txt", b"keep\r\nremove me\r\nalso keep\r\n"); + let re = Regex::new("(?i)remove").unwrap(); + filter_lines_in_file(&path, &re, &[]).unwrap(); + let result = std::fs::read(&path).unwrap(); + assert_eq!(result, b"keep\r\nalso keep\r\n"); + } + + #[test] + fn filter_no_match_does_not_write() { + let content = b"line one\nline two\n"; + let path = write_temp("mulder_nomatch.txt", content); + let modified_before = std::fs::metadata(&path).unwrap().modified().unwrap(); + let re = Regex::new("(?i)no match here").unwrap(); + filter_lines_in_file(&path, &re, &[]).unwrap(); + let modified_after = std::fs::metadata(&path).unwrap().modified().unwrap(); + assert_eq!( + modified_before, modified_after, + "file should not be written when no match" + ); + } + + #[test] + fn filter_removes_line_from_file_without_trailing_newline() { + // Bug: removing the last line from a file without trailing \n + // used to produce a spurious trailing \n in the output. + let path = write_temp("mulder_no_trailing_nl.txt", b"keep\nremove me"); + let re = Regex::new("(?i)remove").unwrap(); + filter_lines_in_file(&path, &re, &[]).unwrap(); + let result = std::fs::read(&path).unwrap(); + assert_eq!(result, b"keep"); + } + + // ── replace_line with escaped \n in replacement ─────────────────────────── + + #[test] + fn replace_line_escaped_newline_expands_to_multiple_lines() { + // A replacement value like "a\nb" in JSON expands to two lines. + let path = write_temp("mulder_multiline_rep.txt", b"before\nold\nafter\n"); + let errors = execute_operations( + &[op_replace_line( + &[path.to_str().unwrap()], + "old", + "new1\\nnew2", + )], + &Selections::new(), + ); + assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors); + let result = std::fs::read_to_string(&path).unwrap(); + assert_eq!(result, "before\nnew1\nnew2\nafter\n"); + } + + #[test] + fn replace_line_multiline_replacement_uses_crlf_in_crlf_file() { + // When the file uses CRLF and the replacement contains \n, + // the separator between the inserted lines must be \r\n, not \n. + let path = write_temp( + "mulder_crlf_multiline_rep.txt", + b"before\r\nAntiAliasing = 8x\r\nafter\r\n", + ); + let errors = execute_operations( + &[op_replace_line( + &[path.to_str().unwrap()], + "AntiAliasing = 8x", + "AntiAliasing = 8x\\nAAMethod = FXAA", + )], + &Selections::new(), + ); + assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors); + let result = std::fs::read(&path).unwrap(); + assert_eq!( + result, b"before\r\nAntiAliasing = 8x\r\nAAMethod = FXAA\r\nafter\r\n", + "Replacement lines must be separated by \\r\\n in a CRLF file" + ); + } + + #[test] + fn replace_line_empty_segments_in_replacement_are_ignored() { + // Double \n in replacement ("a\n\nb") produces two lines, not three — + // the empty segment between the two \n is filtered out. + let path = write_temp("mulder_empty_seg.txt", b"before\nold\nafter\n"); + let errors = execute_operations( + &[op_replace_line( + &[path.to_str().unwrap()], + "old", + "new1\\n\\nnew2", + )], + &Selections::new(), + ); + assert!(errors.is_empty(), "Expected no errors, got: {:?}", errors); + let result = std::fs::read_to_string(&path).unwrap(); + assert_eq!(result, "before\nnew1\nnew2\nafter\n"); + } +} diff --git a/src/apply/launcher.rs b/src/apply/launcher.rs new file mode 100644 index 0000000..21605b6 --- /dev/null +++ b/src/apply/launcher.rs @@ -0,0 +1,302 @@ +use std::path::{Path, PathBuf}; + +use crate::config::model::ConfigModel; +use crate::config::when_resolver::{Selections, match_when}; + +use super::exe_replacer; + +pub struct ResolvedLaunch { + pub exe: PathBuf, + pub work_dir: PathBuf, + pub wait: bool, + pub args: Vec, +} + +fn resolve_launch(config: &ConfigModel, selected: &Selections) -> ResolvedLaunch { + let mut exe = exe_replacer::get_default_launch_exe(config); + let mut work_dir = exe_dir(); + let mut wait = false; + let mut args: Vec = Vec::new(); + + for rule in &config.actions.launch { + if let Some(when) = &rule.when + && !match_when(when, selected) + { + continue; + } + // Last match wins for exec + if let Some(exec) = &rule.exec { + exe = resolve_path(&exec.name); + work_dir = resolve_path(&exec.work_dir); + wait = exec.wait.unwrap_or(false); + } + // Cumulative args — split on whitespace so "-addon 1" becomes ["-addon", "1"] + if let Some(rule_args) = &rule.args { + for a in rule_args { + args.extend(a.split_whitespace().map(str::to_owned)); + } + } + } + + ResolvedLaunch { + exe, + work_dir, + wait, + args, + } +} + +pub fn launch(config: &ConfigModel, selected: &Selections) -> Result<(), String> { + let resolved = resolve_launch(config, selected); + + if !resolved.exe.exists() { + return Err(format!("Executable not found: {}", resolved.exe.display())); + } + + let mut child = std::process::Command::new(&resolved.exe) + .current_dir(&resolved.work_dir) + .args(&resolved.args) + .spawn() + .map_err(|e| format!("Failed to launch '{}': {e}", resolved.exe.display()))?; + + if resolved.wait { + child + .wait() + .map_err(|e| format!("Process wait failed: {e}"))?; + } + + Ok(()) +} + +fn resolve_path(path: &str) -> PathBuf { + let expanded = super::file_ops::expand_env_vars(path); + let p = Path::new(&expanded); + if p.is_absolute() { + p.to_path_buf() + } else { + exe_dir().join(p) + } +} + +fn exe_dir() -> PathBuf { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())) + .unwrap_or_else(|| PathBuf::from(".")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::model::*; + use crate::config::when_resolver::SelectionValue; + + fn parse_config(json: &str) -> ConfigModel { + serde_json::from_str(json).expect("JSON parse failed") + } + + fn sel(items: &[(&str, &str)]) -> Selections { + items + .iter() + .map(|(k, v)| (k.to_string(), SelectionValue::Single(v.to_string()))) + .collect() + } + + #[test] + fn returns_default_exe_when_no_rule_matches() { + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "optionGroups": [], + "actions": { + "launch": [ + { + "when": [ { "Renderer": "DX9" } ], + "exec": { "name": "dx9.exe", "workDir": ".\\" }, + "args": ["-a"] + } + ], + "operations": [] + } + }"#; + + let config = parse_config(json); + let selected = sel(&[("Renderer", "DX11")]); + + let resolved = resolve_launch(&config, &selected); + + assert_eq!( + resolved.exe.file_name().unwrap(), + "Game.exe", + "Default exe should be Game.exe when no rule matches" + ); + assert_eq!(resolved.work_dir, exe_dir()); + assert!(resolved.args.is_empty()); + } + + #[test] + fn args_append_and_last_exec_wins() { + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "optionGroups": [], + "actions": { + "launch": [ + { + "when": [ { "Renderer": "DX9" } ], + "exec": { "name": "dx9.exe", "workDir": ".\\" }, + "args": ["-nosetup"] + }, + { + "when": [ { "Renderer": "DX9" } ], + "exec": { "name": "dx9_alt.exe", "workDir": "C:\\tmp" }, + "args": ["-novsync", "-borderless"] + } + ], + "operations": [] + } + }"#; + + let config = parse_config(json); + let selected = sel(&[("Renderer", "DX9")]); + + let resolved = resolve_launch(&config, &selected); + + assert_eq!( + resolved.exe.file_name().unwrap(), + "dx9_alt.exe", + "Last matching exec should win" + ); + assert_eq!(resolved.work_dir, PathBuf::from("C:\\tmp")); + assert_eq!(resolved.args, vec!["-nosetup", "-novsync", "-borderless"]); + } + + #[test] + fn rule_without_when_always_applies() { + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "optionGroups": [], + "actions": { + "launch": [ + { + "exec": { "name": "always.exe", "workDir": ".\\" }, + "args": ["-always"] + } + ], + "operations": [] + } + }"#; + + let config = parse_config(json); + let resolved = resolve_launch(&config, &sel(&[])); + + assert_eq!(resolved.exe.file_name().unwrap(), "always.exe"); + assert_eq!(resolved.args, vec!["-always"]); + } + + #[test] + fn wait_flag_propagated() { + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "optionGroups": [], + "actions": { + "launch": [ + { + "exec": { "name": "game.exe", "workDir": ".\\", "wait": true } + } + ], + "operations": [] + } + }"#; + + let config = parse_config(json); + let resolved = resolve_launch(&config, &sel(&[])); + + assert!(resolved.wait); + } + + #[test] + fn args_only_rule_does_not_change_exe() { + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "optionGroups": [], + "actions": { + "launch": [ + { + "exec": { "name": "game.exe", "workDir": ".\\" } + }, + { + "when": [ { "Renderer": "DX9" } ], + "args": ["-dx9"] + } + ], + "operations": [] + } + }"#; + + let config = parse_config(json); + let resolved = resolve_launch(&config, &sel(&[("Renderer", "DX9")])); + + assert_eq!(resolved.exe.file_name().unwrap(), "game.exe"); + assert_eq!(resolved.args, vec!["-dx9"]); + } + + #[test] + fn resolve_path_expands_env_vars_in_exec_name() { + // %SystemRoot% is always set on Windows (e.g. C:\Windows). + let sys = std::env::var("SystemRoot").unwrap(); + let json = format!( + r#" + {{ + "game": {{ "title": "Test", "originalExe": "Game.exe" }}, + "optionGroups": [], + "actions": {{ + "launch": [ + {{ + "exec": {{ "name": "%SystemRoot%\\System32\\notepad.exe", "workDir": ".\\" }} + }} + ], + "operations": [] + }} + }} + "# + ); + + let config = parse_config(&json); + let resolved = resolve_launch(&config, &sel(&[])); + + let expected = PathBuf::from(format!("{}\\System32\\notepad.exe", sys)); + assert_eq!( + resolved.exe, expected, + "%%SystemRoot%% in exec.name should be expanded" + ); + } + + #[test] + fn args_with_spaces_are_split_into_separate_args() { + // Mirrors the C# behaviour: args are joined into a single string and + // parsed by the Windows command-line parser, so "-addon 1" becomes + // two distinct argv entries. In Rust we must split explicitly. + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "optionGroups": [], + "actions": { + "launch": [ + { + "args": ["-addon 1", "-gamegrp duke3d.grp"] + } + ], + "operations": [] + } + }"#; + + let config = parse_config(json); + let resolved = resolve_launch(&config, &sel(&[])); + + assert_eq!(resolved.args, vec!["-addon", "1", "-gamegrp", "duke3d.grp"]); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..cc2e739 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,4 @@ +pub mod loader; +pub mod model; +pub mod validator; +pub mod when_resolver; diff --git a/src/config/loader.rs b/src/config/loader.rs new file mode 100644 index 0000000..6dcac73 --- /dev/null +++ b/src/config/loader.rs @@ -0,0 +1,222 @@ +use std::fs; + +use crate::config::model::ConfigModel; +use crate::config::validator::ConfigValidator; + +pub struct ConfigLoader; + +impl ConfigLoader { + pub fn load(path: &str) -> Result { + // Read file + let content = + fs::read_to_string(path).map_err(|e| format!("Failed to read config file: {e}"))?; + + // Parse JSON + let config: ConfigModel = + serde_json::from_str(&content).map_err(|e| format!("Invalid JSON: {e}"))?; + + // Validate + ConfigValidator::validate(&config)?; + + Ok(config) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::when_resolver::{SelectionValue, Selections, match_when}; + + fn parse(json: &str) -> ConfigModel { + serde_json::from_str(json).expect("JSON parse failed") + } + + fn sel(items: &[(&str, &str)]) -> Selections { + items + .iter() + .map(|(k, v)| (k.to_string(), SelectionValue::Single(v.to_string()))) + .collect() + } + + // === ConfigJsonTests === + + #[test] + fn partial_json_deserializes_launch_and_operations() { + let json = r#" + { + "game": { "title": "Test Game", "originalExe": "Game.exe" }, + "optionGroups": [], + "actions": { + "launch": [ + { + "when": [ { "Renderer": "DX9" } ], + "exec": { "name": "dx9.exe", "workDir": ".\\" }, + "args": ["-a"] + } + ], + "operations": [ + { + "when": [ { "Renderer": "DX9" } ], + "operation": "rename", + "source": "a.dll", + "target": "b.dll" + }, + { + "operation": "replaceLine", + "files": ["FalloutPrefs.ini"], + "pattern": "^iSize W=.*$", + "replacement": "iSize W=1920" + } + ] + } + }"#; + + let config = parse(json); + + assert_eq!("Test Game", config.game.title); + assert_eq!("Game.exe", config.game.original_exe); + + assert_eq!(1, config.actions.launch.len()); + let launch = &config.actions.launch[0]; + assert_eq!("dx9.exe", launch.exec.as_ref().unwrap().name); + assert_eq!(r".\", launch.exec.as_ref().unwrap().work_dir); + assert_eq!(vec!["-a"], *launch.args.as_ref().unwrap()); + + assert_eq!(2, config.actions.operations.len()); + assert_eq!("rename", config.actions.operations[0].operation); + assert_eq!( + Some("a.dll".to_string()), + config.actions.operations[0].source + ); + assert_eq!( + Some("b.dll".to_string()), + config.actions.operations[0].target + ); + assert_eq!("replaceLine", config.actions.operations[1].operation); + assert_eq!( + Some(vec!["FalloutPrefs.ini".to_string()]), + config.actions.operations[1].files + ); + } + + #[test] + fn launch_rules_args_append_and_last_exec_wins() { + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "optionGroups": [], + "actions": { + "launch": [ + { + "when": [ { "Renderer": "DX9" } ], + "exec": { "name": "dx9.exe", "workDir": ".\\" }, + "args": ["-nosetup"] + }, + { + "when": [ { "Renderer": "DX9" } ], + "exec": { "name": "dx9_alt.exe", "workDir": "C:\\tmp" }, + "args": ["-novsync", "-borderless"] + } + ], + "operations": [] + } + }"#; + + let config = parse(json); + let selected = sel(&[("Renderer", "DX9")]); + + // Manually apply resolution logic (mirrors LaunchManager / ConfigJsonTests helper) + let mut exe = config.game.original_exe.clone(); + let mut work_dir = String::from(".\\"); + let mut args: Vec = Vec::new(); + for rule in &config.actions.launch { + if let Some(when) = &rule.when { + if !match_when(when, &selected) { + continue; + } + } + if let Some(exec) = &rule.exec { + exe = exec.name.clone(); + work_dir = exec.work_dir.clone(); + } + if let Some(rule_args) = &rule.args { + args.extend(rule_args.clone()); + } + } + + assert_eq!("dx9_alt.exe", exe); + assert_eq!("C:\\tmp", work_dir); + assert_eq!(vec!["-nosetup", "-novsync", "-borderless"], args); + } + + #[test] + fn null_when_is_treated_as_always_apply() { + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "optionGroups": [], + "actions": { + "launch": [ { "args": ["-a"] } ], + "operations": [ { "operation": "removeLine", "files": ["a.ini"], "pattern": "^x=.*$" } ] + } + }"#; + + let config = parse(json); + let selected = sel(&[("Renderer", "Anything")]); + + // when: None in JSON → None in Rust → empty slice → always applies + assert!(config.actions.launch[0].when.is_none()); + let empty: &[crate::config::model::WhenGroup] = &[]; + assert!(match_when( + config.actions.launch[0].when.as_deref().unwrap_or(empty), + &selected + )); + + assert!(config.actions.operations[0].when.is_none()); + assert!(match_when( + config.actions.operations[0] + .when + .as_deref() + .unwrap_or(empty), + &selected + )); + } + + #[test] + fn missing_launch_section_defaults_to_empty_and_config_is_valid() { + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "addons": [ { "title": "default", "steamId": 1 } ], + "optionGroups": [], + "actions": { + "operations": [ { "operation": "delete", "source": "tmp.txt" } ] + } + }"#; + + let config = parse(json); + + assert!(ConfigValidator::validate(&config).is_ok()); + assert!(config.actions.launch.is_empty()); + assert_eq!(1, config.actions.operations.len()); + } + + #[test] + fn missing_operations_section_defaults_to_empty_and_config_is_valid() { + let json = r#" + { + "game": { "title": "Test", "originalExe": "Game.exe" }, + "addons": [ { "title": "default", "steamId": 1 } ], + "optionGroups": [], + "actions": { + "launch": [ { "args": ["-a"] } ] + } + }"#; + + let config = parse(json); + + assert!(ConfigValidator::validate(&config).is_ok()); + assert!(config.actions.operations.is_empty()); + assert_eq!(1, config.actions.launch.len()); + } +} diff --git a/src/config/model.rs b/src/config/model.rs new file mode 100644 index 0000000..69ba25f --- /dev/null +++ b/src/config/model.rs @@ -0,0 +1,98 @@ +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigModel { + pub game: Game, + pub addons: Option>, + pub option_groups: Vec, + pub actions: ActionRoot, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Game { + pub title: String, + pub original_exe: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Addon { + pub title: String, + pub steam_id: u32, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum OptionGroupType { + RadioGroup, + CheckboxGroup, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OptionGroup { + pub name: String, + + #[serde(alias = "type")] + pub kind: OptionGroupType, + + pub radios: Option>, + pub checkboxes: Option>, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Radio { + pub value: String, + pub disabled_when: Option>, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Checkbox { + pub value: String, + pub disabled_when: Option>, +} + +pub type WhenGroup = IndexMap; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionRoot { + #[serde(default)] + pub launch: Vec, + #[serde(default)] + pub operations: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchAction { + pub when: Option>, + pub exec: Option, + pub args: Option>, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecSpec { + pub name: String, + pub work_dir: String, + pub wait: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OperationAction { + pub when: Option>, + pub operation: String, + pub source: Option, + pub target: Option, + pub files: Option>, + pub pattern: Option, + pub search: Option, + pub replacement: Option, +} diff --git a/src/config/validator.rs b/src/config/validator.rs new file mode 100644 index 0000000..f0bb3a4 --- /dev/null +++ b/src/config/validator.rs @@ -0,0 +1,417 @@ +use crate::config::model::{ConfigModel, OptionGroupType}; +use std::collections::HashSet; + +pub struct ConfigValidator; + +impl ConfigValidator { + pub fn validate(config: &ConfigModel) -> Result<(), String> { + // GAME + if config.game.title.trim().is_empty() { + return Err("Game title is empty".into()); + } + + if config.game.original_exe.trim().is_empty() { + return Err("OriginalExe is empty".into()); + } + + // OPTION GROUPS + let mut names = HashSet::new(); + + for g in &config.option_groups { + if g.name.trim().is_empty() { + return Err("OptionGroup name is empty".into()); + } + + if !names.insert(g.name.to_lowercase()) { + return Err("Duplicate OptionGroup name".into()); + } + + match g.kind { + OptionGroupType::RadioGroup => { + let radios = g.radios.as_ref().ok_or("RadioGroup must have radios")?; + + if radios.is_empty() { + return Err("RadioGroup cannot be empty".into()); + } + + if g.checkboxes.is_some() { + return Err("RadioGroup cannot have checkboxes".into()); + } + + let mut values: HashSet = HashSet::new(); + for r in radios { + if r.value.trim().is_empty() { + return Err("Radio value is empty".into()); + } + if !values.insert(r.value.to_lowercase()) { + return Err(format!("Duplicate radio value '{}'", r.value)); + } + } + } + + OptionGroupType::CheckboxGroup => { + let checkboxes = g + .checkboxes + .as_ref() + .ok_or("CheckboxGroup must have checkboxes")?; + + if checkboxes.is_empty() { + return Err("CheckboxGroup cannot be empty".into()); + } + + if g.radios.is_some() { + return Err("CheckboxGroup cannot have radios".into()); + } + + let mut values: HashSet = HashSet::new(); + for c in checkboxes { + if c.value.trim().is_empty() { + return Err("Checkbox value is empty".into()); + } + if !values.insert(c.value.to_lowercase()) { + return Err(format!("Duplicate checkbox value '{}'", c.value)); + } + } + } + } + } + + // ACTIONS + if config.actions.launch.is_empty() && config.actions.operations.is_empty() { + return Err("At least one action is required".into()); + } + + for rule in &config.actions.launch { + if let Some(exec) = &rule.exec { + if exec.name.trim().is_empty() { + return Err("Exec name is empty".into()); + } + if exec.work_dir.trim().is_empty() { + return Err("Exec workDir is empty".into()); + } + } + } + + for op in &config.actions.operations { + let operation = op.operation.trim().to_lowercase(); + if operation.is_empty() { + return Err("Operation name is empty".into()); + } + match operation.as_str() { + "rename" | "move" | "copy" => { + let src_ok = op + .source + .as_ref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + let dst_ok = op + .target + .as_ref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + if !src_ok || !dst_ok { + return Err(format!( + "Operation '{operation}' requires source and target" + )); + } + } + "delete" => { + if op + .source + .as_ref() + .map(|s| s.trim().is_empty()) + .unwrap_or(true) + { + return Err("Operation 'delete' requires source".into()); + } + } + "setreadonly" | "removereadonly" => { + if op.files.as_ref().map(|f| f.is_empty()).unwrap_or(true) { + return Err(format!("Operation '{operation}' requires files")); + } + } + "replaceline" | "removeline" => { + if op.files.as_ref().map(|f| f.is_empty()).unwrap_or(true) { + return Err(format!("Operation '{operation}' requires files")); + } + if op + .pattern + .as_ref() + .map(|p| p.trim().is_empty()) + .unwrap_or(true) + { + return Err(format!("Operation '{operation}' requires pattern")); + } + if operation == "replaceline" && op.replacement.is_none() { + return Err("Operation 'replaceLine' requires replacement".into()); + } + } + "replacetext" => { + if op.files.as_ref().map(|f| f.is_empty()).unwrap_or(true) { + return Err("Operation 'replaceText' requires files".into()); + } + if op + .search + .as_ref() + .map(|s| s.trim().is_empty()) + .unwrap_or(true) + { + return Err("Operation 'replaceText' requires search".into()); + } + if op.replacement.is_none() { + return Err("Operation 'replaceText' requires replacement".into()); + } + } + _ => {} + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::model::*; + + fn minimal_valid() -> ConfigModel { + ConfigModel { + game: Game { + title: "Test".into(), + original_exe: "Game.exe".into(), + }, + addons: Some(vec![Addon { + title: "default".into(), + steam_id: 1, + }]), + option_groups: vec![OptionGroup { + name: "Renderer".into(), + kind: OptionGroupType::RadioGroup, + radios: Some(vec![Radio { + value: "DX9".into(), + disabled_when: None, + }]), + checkboxes: None, + }], + actions: ActionRoot { + launch: vec![LaunchAction { + when: None, + exec: Some(ExecSpec { + name: "Game.exe".into(), + work_dir: ".\\".into(), + wait: None, + }), + args: Some(vec!["-a".into()]), + }], + operations: vec![OperationAction { + when: None, + operation: "delete".into(), + source: Some("tmp.txt".into()), + target: None, + files: None, + pattern: None, + search: None, + replacement: None, + }], + }, + } + } + + #[test] + fn valid_for_minimal_valid_config() { + assert!(ConfigValidator::validate(&minimal_valid()).is_ok()); + } + + #[test] + fn valid_when_addon_list_missing() { + let mut cfg = minimal_valid(); + cfg.addons = None; + assert!(ConfigValidator::validate(&cfg).is_ok()); + } + + #[test] + fn err_when_radio_group_has_no_radios() { + let mut cfg = minimal_valid(); + cfg.option_groups[0].radios = Some(vec![]); + assert!(ConfigValidator::validate(&cfg).is_err()); + } + + #[test] + fn err_when_launch_exec_name_is_empty() { + let mut cfg = minimal_valid(); + cfg.actions.launch[0].exec = Some(ExecSpec { + name: "".into(), + work_dir: ".\\".into(), + wait: None, + }); + assert!(ConfigValidator::validate(&cfg).is_err()); + } + + #[test] + fn err_when_rename_missing_target() { + let mut cfg = minimal_valid(); + cfg.actions.operations = vec![OperationAction { + when: None, + operation: "rename".into(), + source: Some("a.dll".into()), + target: None, + files: None, + pattern: None, + search: None, + replacement: None, + }]; + assert!(ConfigValidator::validate(&cfg).is_err()); + } + + #[test] + fn err_when_set_readonly_has_no_files() { + let mut cfg = minimal_valid(); + cfg.actions.operations = vec![OperationAction { + when: None, + operation: "setReadOnly".into(), + source: None, + target: None, + files: Some(vec![]), + pattern: None, + search: None, + replacement: None, + }]; + assert!(ConfigValidator::validate(&cfg).is_err()); + } + + #[test] + fn err_when_radio_has_empty_value() { + let mut cfg = minimal_valid(); + cfg.option_groups[0].radios = Some(vec![ + Radio { + value: "DX9".into(), + disabled_when: None, + }, + Radio { + value: "".into(), + disabled_when: None, + }, + ]); + assert!(ConfigValidator::validate(&cfg).is_err()); + } + + #[test] + fn err_when_radio_has_duplicate_value() { + let mut cfg = minimal_valid(); + cfg.option_groups[0].radios = Some(vec![ + Radio { + value: "DX9".into(), + disabled_when: None, + }, + Radio { + value: "DX9".into(), + disabled_when: None, + }, + ]); + assert!(ConfigValidator::validate(&cfg).is_err()); + } + + #[test] + fn err_when_radio_has_duplicate_value_case_insensitive() { + let mut cfg = minimal_valid(); + cfg.option_groups[0].radios = Some(vec![ + Radio { + value: "dx9".into(), + disabled_when: None, + }, + Radio { + value: "DX9".into(), + disabled_when: None, + }, + ]); + assert!(ConfigValidator::validate(&cfg).is_err()); + } + + #[test] + fn err_when_checkbox_has_empty_value() { + let mut cfg = minimal_valid(); + cfg.option_groups[0].kind = OptionGroupType::CheckboxGroup; + cfg.option_groups[0].radios = None; + cfg.option_groups[0].checkboxes = Some(vec![ + Checkbox { + value: "MSAA".into(), + disabled_when: None, + }, + Checkbox { + value: "".into(), + disabled_when: None, + }, + ]); + assert!(ConfigValidator::validate(&cfg).is_err()); + } + + #[test] + fn err_when_checkbox_has_duplicate_value() { + let mut cfg = minimal_valid(); + cfg.option_groups[0].kind = OptionGroupType::CheckboxGroup; + cfg.option_groups[0].radios = None; + cfg.option_groups[0].checkboxes = Some(vec![ + Checkbox { + value: "MSAA".into(), + disabled_when: None, + }, + Checkbox { + value: "MSAA".into(), + disabled_when: None, + }, + ]); + assert!(ConfigValidator::validate(&cfg).is_err()); + } + + #[test] + fn err_when_checkbox_has_duplicate_value_case_insensitive() { + let mut cfg = minimal_valid(); + cfg.option_groups[0].kind = OptionGroupType::CheckboxGroup; + cfg.option_groups[0].radios = None; + cfg.option_groups[0].checkboxes = Some(vec![ + Checkbox { + value: "msaa".into(), + disabled_when: None, + }, + Checkbox { + value: "MSAA".into(), + disabled_when: None, + }, + ]); + assert!(ConfigValidator::validate(&cfg).is_err()); + } + + #[test] + fn valid_when_set_readonly_has_files() { + let mut cfg = minimal_valid(); + cfg.actions.operations = vec![OperationAction { + when: None, + operation: "setReadOnly".into(), + source: None, + target: None, + files: Some(vec!["Fallout.ini".into()]), + pattern: None, + search: None, + replacement: None, + }]; + assert!(ConfigValidator::validate(&cfg).is_ok()); + } + + #[test] + fn valid_when_replace_text_has_empty_replacement() { + // empty replacement is a valid "delete all occurrences of search text" use case + let mut cfg = minimal_valid(); + cfg.actions.operations = vec![OperationAction { + when: None, + operation: "replaceText".into(), + source: None, + target: None, + files: Some(vec!["Fallout.ini".into()]), + pattern: None, + search: Some("OldText".into()), + replacement: Some("".into()), + }]; + assert!(ConfigValidator::validate(&cfg).is_ok()); + } +} diff --git a/src/config/when_resolver.rs b/src/config/when_resolver.rs new file mode 100644 index 0000000..61fac7a --- /dev/null +++ b/src/config/when_resolver.rs @@ -0,0 +1,335 @@ +use std::collections::HashMap; + +use crate::config::model::WhenGroup; + +/// Runtime selection value: single string (radio) or list of strings (checkboxes). +#[derive(Debug, Clone)] +pub enum SelectionValue { + Single(String), + Multiple(Vec), +} + +impl From for SelectionValue { + fn from(s: String) -> Self { + SelectionValue::Single(s) + } +} + +impl From> for SelectionValue { + fn from(v: Vec) -> Self { + SelectionValue::Multiple(v) + } +} + +pub type Selections = HashMap; + +#[derive(Debug, PartialEq)] +enum ConditionOperator { + Equals, + NotEquals, + Contains, + NotContains, +} + +fn parse_key(raw_key: &str) -> (ConditionOperator, &str) { + if let Some(s) = raw_key.strip_prefix("!*") { + (ConditionOperator::NotContains, s) + } else if let Some(s) = raw_key.strip_prefix('*') { + (ConditionOperator::Contains, s) + } else if let Some(s) = raw_key.strip_prefix('!') { + (ConditionOperator::NotEquals, s) + } else { + (ConditionOperator::Equals, raw_key) + } +} + +fn contains_ignore_case(actual: &str, expected: &str) -> bool { + actual.to_lowercase().contains(&expected.to_lowercase()) +} + +fn equals_ignore_case(a: &str, b: &str) -> bool { + a.eq_ignore_ascii_case(b) +} + +fn is_null_or_empty_selection(value: Option<&SelectionValue>) -> bool { + match value { + None => true, + Some(SelectionValue::Multiple(v)) => v.is_empty(), + Some(_) => false, + } +} + +fn is_value_match(value: &SelectionValue, expected: &str, op: &ConditionOperator) -> bool { + match value { + SelectionValue::Multiple(list) => match op { + ConditionOperator::Contains => list.iter().any(|v| contains_ignore_case(v, expected)), + ConditionOperator::NotContains => { + !list.iter().any(|v| contains_ignore_case(v, expected)) + } + ConditionOperator::NotEquals => !list.iter().any(|v| equals_ignore_case(v, expected)), + ConditionOperator::Equals => list.iter().any(|v| equals_ignore_case(v, expected)), + }, + SelectionValue::Single(actual) => match op { + ConditionOperator::Contains => contains_ignore_case(actual, expected), + ConditionOperator::NotContains => !contains_ignore_case(actual, expected), + ConditionOperator::NotEquals => !equals_ignore_case(actual, expected), + ConditionOperator::Equals => equals_ignore_case(actual, expected), + }, + } +} + +fn is_group_match(group: &WhenGroup, selected: &Selections) -> bool { + for (raw_key, expected) in group { + let (op, key) = parse_key(raw_key); + let selected_value = selected.get(key); + + // Special case: expected is "" with Equals => "nothing selected" (empty checkbox list) + if op == ConditionOperator::Equals && expected.is_empty() { + if is_null_or_empty_selection(selected_value) { + continue; + } + return false; + } + + // Missing key: + // - NotEquals / NotContains => considered true + // - Equals / Contains => cannot match + match selected_value { + None => { + if op == ConditionOperator::NotEquals || op == ConditionOperator::NotContains { + continue; + } + return false; + } + Some(value) => { + if !is_value_match(value, expected, &op) { + return false; + } + } + } + } + + true +} + +/// Returns true if any WhenGroup matches the current selections (OR of ANDs). +/// An empty groups slice means "always apply". +pub fn match_when(groups: &[WhenGroup], selected: &Selections) -> bool { + if groups.is_empty() { + return true; + } + + groups.iter().any(|group| is_group_match(group, selected)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sel(items: &[(&str, SelectionValue)]) -> Selections { + items + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect() + } + + fn single(s: &str) -> SelectionValue { + SelectionValue::Single(s.to_string()) + } + + fn multi(items: &[&str]) -> SelectionValue { + SelectionValue::Multiple(items.iter().map(|s| s.to_string()).collect()) + } + + fn when(groups: &[&[(&str, &str)]]) -> Vec { + groups + .iter() + .map(|group| { + group + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + }) + .collect() + } + + #[test] + fn empty_when_always_matches() { + assert!(match_when(&[], &Selections::new())); + } + + #[test] + fn and_all_conditions_match_succeeds() { + let groups = when(&[&[("Renderer", "DX9"), ("HDR", "Enabled")]]); + let selected = sel(&[("Renderer", single("DX9")), ("HDR", single("Enabled"))]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn and_one_condition_miss_fails() { + let groups = when(&[&[("Renderer", "DX9"), ("HDR", "Enabled")]]); + let selected = sel(&[("Renderer", single("DX11")), ("HDR", single("Enabled"))]); + assert!(!match_when(&groups, &selected)); + } + + #[test] + fn and_no_condition_matches_fails() { + let groups = when(&[&[("Renderer", "DX9"), ("HDR", "Enabled")]]); + let selected = sel(&[("Renderer", single("DX11")), ("HDR", single("Disabled"))]); + assert!(!match_when(&groups, &selected)); + } + + #[test] + fn or_all_groups_match_succeeds() { + let groups = when(&[&[("Resolution", "2560x1440")], &[("Renderer", "DXVK")]]); + let selected = sel(&[ + ("Resolution", single("2560x1440")), + ("Renderer", single("DXVK")), + ]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn or_one_group_matches_succeeds() { + let groups = when(&[&[("Resolution", "2560x1440")], &[("Renderer", "DXVK")]]); + let selected = sel(&[("Renderer", single("DXVK"))]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn or_no_group_matches_fails() { + let groups = when(&[&[("Resolution", "2560x1440")], &[("Renderer", "DXVK")]]); + let selected = sel(&[ + ("Resolution", single("1920x1080")), + ("Renderer", single("D3D9")), + ]); + assert!(!match_when(&groups, &selected)); + } + + #[test] + fn or_of_and_groups_mixed_example_succeeds() { + // (*Resolution contains "1920x" AND Renderer == "DXVK") OR (!FOV Modifier != "None") + let groups = when(&[ + &[("*Resolution", "1920x"), ("Renderer", "DXVK")], + &[("!FOV Modifier", "None")], + ]); + // First group fails (Renderer != DXVK), second succeeds => OR => true + let selected = sel(&[ + ("Resolution", single("1920x1080")), + ("Renderer", single("D3D9")), + ("FOV Modifier", single("lower")), + ]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn not_equals_succeeds() { + let groups = when(&[&[("!Renderer", "DXVK")]]); + let selected = sel(&[("Renderer", single("DX9"))]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn not_equals_fails() { + let groups = when(&[&[("!Resolution", "1920x1080")]]); + let selected = sel(&[("Resolution", single("1920x1080"))]); + assert!(!match_when(&groups, &selected)); + } + + #[test] + fn contains_succeeds() { + let groups = when(&[&[("*Resolution", "1920x")]]); + let selected = sel(&[("Resolution", single("1920x1080"))]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn contains_fails() { + let groups = when(&[&[("*Resolution", "2560x")]]); + let selected = sel(&[("Resolution", single("1920x1080"))]); + assert!(!match_when(&groups, &selected)); + } + + #[test] + fn not_contains_succeeds() { + let groups = when(&[&[("!*Renderer", "DXVK")]]); + let selected = sel(&[("Renderer", single("DX9"))]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn not_contains_fails() { + let groups = when(&[&[("!*Renderer", "DXVK")]]); + let selected = sel(&[("Renderer", single("Vulkan DXVK Wrapper"))]); + assert!(!match_when(&groups, &selected)); + } + + #[test] + fn empty_expected_matches_nothing_selected() { + let groups = when(&[&[("Switchable Mods", "")]]); + let selected = sel(&[("Switchable Mods", multi(&[]))]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn list_contains_succeeds() { + let groups = when(&[&[("*Switchable Mods", "NV")]]); + let selected = sel(&[("Switchable Mods", multi(&["NVHR", "DXVK"]))]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn list_not_contains_fails() { + let groups = when(&[&[("!*Switchable Mods", "Vulkan")]]); + let selected = sel(&[("Switchable Mods", multi(&["NVHR", "Vulkan DXVK Wrapper"]))]); + assert!(!match_when(&groups, &selected)); + } + + #[test] + fn missing_key_equals_fails() { + let groups = when(&[&[("Renderer", "DXVK")]]); + let selected = sel(&[("Resolution", single("1920x1080"))]); + assert!(!match_when(&groups, &selected)); + } + + #[test] + fn missing_key_contains_fails() { + let groups = when(&[&[("*Renderer", "DX")]]); + let selected = sel(&[("Resolution", single("1920x1080"))]); + assert!(!match_when(&groups, &selected)); + } + + #[test] + fn missing_key_not_equals_succeeds() { + let groups = when(&[&[("!Renderer", "DXVK")]]); + let selected = sel(&[("Resolution", single("1920x1080"))]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn missing_key_not_contains_succeeds() { + let groups = when(&[&[("!*Renderer", "DXVK")]]); + let selected = sel(&[("Resolution", single("1920x1080"))]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn case_insensitive_equals_and_contains_work() { + let groups = when(&[&[("Renderer", "dxvk"), ("*Resolution", "1920X")]]); + let selected = sel(&[ + ("Renderer", single("DXVK")), + ("Resolution", single("1920x1080")), + ]); + assert!(match_when(&groups, &selected)); + } + + #[test] + fn case_insensitive_not_equals_and_not_contains_work() { + let groups = when(&[&[("!Renderer", "dxvk"), ("!*Resolution", "(21/9)")]]); + let selected = sel(&[ + ("Renderer", single("DX9")), + ("Resolution", single("1920x1080 (16/9)")), + ]); + assert!(match_when(&groups, &selected)); + } +} diff --git a/src/context.rs b/src/context.rs new file mode 100644 index 0000000..9fbce8d --- /dev/null +++ b/src/context.rs @@ -0,0 +1,40 @@ +use crate::config::model::ConfigModel; +use crate::save::{GroupSelections, SaveData}; + +pub struct AppContext { + pub config: ConfigModel, + pub save_data: SaveData, + pub save_path: String, + pub active_title: String, + pub selections: GroupSelections, +} + +impl AppContext { + pub fn new( + config: ConfigModel, + save_data: SaveData, + save_path: String, + active_title: String, + ) -> Self { + let selections = save_data.get(&active_title).cloned().unwrap_or_default(); + AppContext { + config, + save_data, + save_path, + active_title, + selections, + } + } + + /// Switch the active addon/game and resolve the matching saved selections. + pub fn switch_addon(&mut self, title: String) { + self.selections = self.save_data.get(&title).cloned().unwrap_or_default(); + self.active_title = title; + } + + /// Persist the current UI selections into save_data under active_title. + pub fn save_selections(&mut self, selections: GroupSelections) { + self.selections = selections.clone(); + self.save_data.insert(self.active_title.clone(), selections); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f0a7433 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,96 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod addon_detector; +mod apply; +mod config; +mod context; +mod mode_detector; +mod save; +mod ui; + +use addon_detector::{parse_addon_arg, resolve_addon}; +use config::loader::ConfigLoader; +use context::AppContext; +use mode_detector::{Mode, detect_mode}; +use save::loader::SaveLoader; +use save::validator::SaveValidator; +use ui::error::{ask_delete_save, fatal}; + +const CONFIG_FILE: &str = "MulderConfig.json"; +const SAVE_FILE: &str = "MulderConfig.save.json"; + +fn exe_relative(filename: &str) -> std::path::PathBuf { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.join(filename))) + .unwrap_or_else(|| std::path::PathBuf::from(filename)) +} + +fn main() { + let config_path = exe_relative(CONFIG_FILE); + let save_path = exe_relative(SAVE_FILE); + + let config = ConfigLoader::load(&config_path.to_string_lossy()) + .unwrap_or_else(|e| fatal(&format!("Failed to load config:\n{e}"))); + + let save_data = if save_path.exists() { + let data = SaveLoader::load(&save_path.to_string_lossy()) + .unwrap_or_else(|e| fatal(&format!("Failed to load save file:\n{e}"))); + match SaveValidator::validate(&data, &config) { + Ok(()) => data, + Err(e) => { + let msg = format!( + "Save file is invalid:\n{e}\n\nDelete the save file and continue?\n(Choosing No will close the application)" + ); + if ask_delete_save(&msg) { + std::fs::remove_file(&save_path) + .unwrap_or_else(|e| fatal(&format!("Failed to delete save file:\n{e}"))); + Default::default() + } else { + std::process::exit(0); + } + } + } + } else { + Default::default() + }; + + let args: Vec = std::env::args().collect(); + let active_title = match parse_addon_arg(&args) { + None => config.game.title.clone(), + Some(steam_id) => match resolve_addon(config.addons.as_deref().unwrap_or(&[]), steam_id) { + Ok(title) => title, + Err(msg) => { + ui::error::warn(&msg); + config.game.title.clone() + } + }, + }; + + let ctx = AppContext::new( + config, + save_data, + save_path.to_string_lossy().into_owned(), + active_title, + ); + + let mode = detect_mode(&ctx.config.game.original_exe); + + match mode { + Mode::Config => { + ui::run(ctx); + } + Mode::Apply => { + let errors = apply::apply(&ctx); + if !errors.is_empty() { + for e in &errors { + eprintln!("Error: {e}"); + } + std::process::exit(1); + } + } + Mode::Launch => { + apply::launch(&ctx); + } + } +} diff --git a/src/mode_detector.rs b/src/mode_detector.rs new file mode 100644 index 0000000..83c01bb --- /dev/null +++ b/src/mode_detector.rs @@ -0,0 +1,157 @@ +use std::env; + +#[derive(Debug, PartialEq)] +pub enum Mode { + Config, + Apply, + Launch, +} + +/// If process = originalExe (case insensitive): LAUNCH by default, CONFIG if -MulderConfig flag is present. +/// Else (MulderConfig.exe, or any rename of it): CONFIG by default, APPLY if -apply flag is present. +pub fn detect_mode(original_exe: &str) -> Mode { + let args: Vec = env::args().collect(); + + let exe_name = std::env::current_exe() + .ok() + .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())) + .unwrap_or_default(); + + resolve_mode(original_exe, &exe_name, &args) +} + +/// Pure logic, separated from environment access for testability. +fn resolve_mode(original_exe: &str, exe_name: &str, args: &[String]) -> Mode { + let original_exe_filename = std::path::Path::new(original_exe) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + + let is_original_exe = exe_name.eq_ignore_ascii_case(&original_exe_filename); + + if is_original_exe { + // Running as the game exe (Steam launch after replacement). + // -MulderConfig flag lets the user open the config UI from here. + if args.iter().any(|a| a.eq_ignore_ascii_case("-MulderConfig")) { + Mode::Config + } else { + Mode::Launch + } + } else { + // Running as MulderConfig.exe (or any rename of it). + // -apply flag triggers headless apply. + if args.iter().any(|a| a.eq_ignore_ascii_case("-apply")) { + Mode::Apply + } else { + Mode::Config + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(s: &[&str]) -> Vec { + s.iter().map(|s| s.to_string()).collect() + } + + // --- Running as originalExe --- + + #[test] + fn launch_when_exe_matches_original_exe() { + assert_eq!( + resolve_mode("Game.exe", "Game.exe", &args(&[])), + Mode::Launch + ); + } + + #[test] + fn launch_is_case_insensitive() { + assert_eq!( + resolve_mode("game.exe", "GAME.EXE", &args(&[])), + Mode::Launch + ); + assert_eq!( + resolve_mode("GAME.EXE", "game.exe", &args(&[])), + Mode::Launch + ); + } + + #[test] + fn launch_works_with_path_in_original_exe() { + // original_exe may include a subdirectory: only the filename is compared + assert_eq!( + resolve_mode("rebirth\\Biohazard.exe", "Biohazard.exe", &args(&[])), + Mode::Launch + ); + } + + #[test] + fn mulderconfig_flag_on_original_exe_forces_config() { + assert_eq!( + resolve_mode("Game.exe", "Game.exe", &args(&["-MulderConfig"])), + Mode::Config + ); + } + + #[test] + fn mulderconfig_flag_is_case_insensitive() { + assert_eq!( + resolve_mode("Game.exe", "Game.exe", &args(&["-mulderconfig"])), + Mode::Config + ); + assert_eq!( + resolve_mode("Game.exe", "Game.exe", &args(&["-MULDERCONFIG"])), + Mode::Config + ); + } + + #[test] + fn apply_flag_ignored_on_original_exe() { + // -apply only makes sense on MulderConfig.exe, not on the game exe + assert_eq!( + resolve_mode("Game.exe", "Game.exe", &args(&["-apply"])), + Mode::Launch + ); + } + + // --- Running as MulderConfig.exe (or renamed) --- + + #[test] + fn config_when_exe_does_not_match_original() { + assert_eq!( + resolve_mode("Game.exe", "MulderConfig.exe", &args(&[])), + Mode::Config + ); + } + + #[test] + fn apply_flag_on_mulderconfig_exe_gives_apply() { + assert_eq!( + resolve_mode("Game.exe", "MulderConfig.exe", &args(&["-apply"])), + Mode::Apply + ); + } + + #[test] + fn apply_flag_is_case_insensitive() { + assert_eq!( + resolve_mode("Game.exe", "MulderConfig.exe", &args(&["-Apply"])), + Mode::Apply + ); + assert_eq!( + resolve_mode("Game.exe", "MulderConfig.exe", &args(&["-APPLY"])), + Mode::Apply + ); + } + + #[test] + fn mulderconfig_flag_ignored_on_mulderconfig_exe() { + // -MulderConfig only makes sense on the game exe, so ignored here (config anyway) + assert_eq!( + resolve_mode("Game.exe", "MulderConfig.exe", &args(&["-MulderConfig"])), + Mode::Config + ); + } +} diff --git a/src/save.rs b/src/save.rs new file mode 100644 index 0000000..bbbb5e3 --- /dev/null +++ b/src/save.rs @@ -0,0 +1,19 @@ +pub mod loader; +pub mod saver; +pub mod validator; + +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; + +/// Outer map: game title → group selections. +/// Inner map: group name → selected value(s). +/// IndexMap preserves insertion order so the saved JSON mirrors config/UI order. +pub type GroupSelections = IndexMap; +pub type SaveData = IndexMap; + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(untagged)] +pub enum SaveValue { + Single(String), + Multiple(Vec), +} diff --git a/src/save/loader.rs b/src/save/loader.rs new file mode 100644 index 0000000..9dd4615 --- /dev/null +++ b/src/save/loader.rs @@ -0,0 +1,17 @@ +use std::fs; + +use crate::save::SaveData; + +pub struct SaveLoader; + +impl SaveLoader { + pub fn load(path: &str) -> Result { + let content = + fs::read_to_string(path).map_err(|e| format!("Failed to read save file: {e}"))?; + + let save: SaveData = + serde_json::from_str(&content).map_err(|e| format!("Invalid save JSON: {e}"))?; + + Ok(save) + } +} diff --git a/src/save/saver.rs b/src/save/saver.rs new file mode 100644 index 0000000..f7f93d2 --- /dev/null +++ b/src/save/saver.rs @@ -0,0 +1,16 @@ +use std::fs; + +use crate::save::SaveData; + +pub struct SaveSaver; + +impl SaveSaver { + pub fn save(path: &str, data: &SaveData) -> Result<(), String> { + let content = serde_json::to_string_pretty(data) + .map_err(|e| format!("Failed to serialize save: {e}"))?; + + fs::write(path, &content).map_err(|e| format!("Failed to write save file: {e}"))?; + + Ok(()) + } +} diff --git a/src/save/validator.rs b/src/save/validator.rs new file mode 100644 index 0000000..b4b0a8c --- /dev/null +++ b/src/save/validator.rs @@ -0,0 +1,267 @@ +use crate::config::model::{ConfigModel, OptionGroupType}; +use crate::save::{SaveData, SaveValue}; + +pub struct SaveValidator; + +impl SaveValidator { + pub fn validate(save: &SaveData, config: &ConfigModel) -> Result<(), String> { + let game_save = save + .get(&config.game.title) + .ok_or_else(|| format!("No save data found for game '{}'", config.game.title))?; + + for group in &config.option_groups { + let saved = game_save + .get(&group.name) + .ok_or_else(|| format!("Missing save entry for option group '{}'", group.name))?; + + match group.kind { + OptionGroupType::RadioGroup => { + let radios = group.radios.as_ref().unwrap(); + match saved { + SaveValue::Single(val) => { + if !radios.iter().any(|r| r.value.eq_ignore_ascii_case(val)) { + return Err(format!( + "Invalid radio value '{}' for group '{}'", + val, group.name + )); + } + } + SaveValue::Multiple(_) => { + return Err(format!( + "Expected a single value for radio group '{}'", + group.name + )); + } + } + } + + OptionGroupType::CheckboxGroup => { + let checkboxes = group.checkboxes.as_ref().unwrap(); + match saved { + SaveValue::Multiple(vals) => { + for val in vals { + if !checkboxes.iter().any(|c| c.value.eq_ignore_ascii_case(val)) { + return Err(format!( + "Invalid checkbox value '{}' for group '{}'", + val, group.name + )); + } + } + } + SaveValue::Single(_) => { + return Err(format!( + "Expected multiple values for checkbox group '{}'", + group.name + )); + } + } + } + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::model::*; + use crate::save::{GroupSelections, SaveValue}; + + fn make_config(groups: Vec) -> ConfigModel { + ConfigModel { + game: Game { + title: "Test".into(), + original_exe: "Game.exe".into(), + }, + addons: None, + option_groups: groups, + actions: ActionRoot { + launch: vec![], + operations: vec![], + }, + } + } + + fn make_save(entries: &[(&str, SaveValue)]) -> SaveData { + let inner: GroupSelections = entries + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect(); + let mut outer = SaveData::new(); + outer.insert("Test".into(), inner); + outer + } + + #[test] + fn valid_for_valid_radio_choice() { + let config = make_config(vec![OptionGroup { + name: "Renderer".into(), + kind: OptionGroupType::RadioGroup, + radios: Some(vec![ + Radio { + value: "DX9".into(), + disabled_when: None, + }, + Radio { + value: "DX11".into(), + disabled_when: None, + }, + ]), + checkboxes: None, + }]); + let save = make_save(&[("Renderer", SaveValue::Single("DX9".into()))]); + assert!(SaveValidator::validate(&save, &config).is_ok()); + } + + #[test] + fn err_when_group_does_not_exist_in_save() { + // NOTE: The C# validator iterates save entries and fails when a key is not in config. + // The Rust validator iterates config groups and fails when a group is missing from save. + // Both return an error, but for symmetric reasons. + let config = make_config(vec![OptionGroup { + name: "Renderer".into(), + kind: OptionGroupType::RadioGroup, + radios: Some(vec![Radio { + value: "DX9".into(), + disabled_when: None, + }]), + checkboxes: None, + }]); + let save = make_save(&[("OldGroup", SaveValue::Single("Whatever".into()))]); + assert!(SaveValidator::validate(&save, &config).is_err()); + } + + #[test] + fn err_when_radio_value_does_not_exist() { + let config = make_config(vec![OptionGroup { + name: "Renderer".into(), + kind: OptionGroupType::RadioGroup, + radios: Some(vec![Radio { + value: "DX9".into(), + disabled_when: None, + }]), + checkboxes: None, + }]); + let save = make_save(&[("Renderer", SaveValue::Single("DX12".into()))]); + assert!(SaveValidator::validate(&save, &config).is_err()); + } + + #[test] + fn valid_for_valid_checkbox_choices() { + let config = make_config(vec![OptionGroup { + name: "Mods".into(), + kind: OptionGroupType::CheckboxGroup, + radios: None, + checkboxes: Some(vec![ + Checkbox { + value: "A".into(), + disabled_when: None, + }, + Checkbox { + value: "B".into(), + disabled_when: None, + }, + Checkbox { + value: "C".into(), + disabled_when: None, + }, + ]), + }]); + let save = make_save(&[("Mods", SaveValue::Multiple(vec!["A".into(), "C".into()]))]); + assert!(SaveValidator::validate(&save, &config).is_ok()); + } + + #[test] + fn err_when_checkbox_value_does_not_exist() { + let config = make_config(vec![OptionGroup { + name: "Mods".into(), + kind: OptionGroupType::CheckboxGroup, + radios: None, + checkboxes: Some(vec![Checkbox { + value: "A".into(), + disabled_when: None, + }]), + }]); + let save = make_save(&[("Mods", SaveValue::Multiple(vec!["A".into(), "B".into()]))]); + assert!(SaveValidator::validate(&save, &config).is_err()); + } + + #[test] + fn valid_when_radio_value_differs_only_in_case() { + let config = make_config(vec![OptionGroup { + name: "Renderer".into(), + kind: OptionGroupType::RadioGroup, + radios: Some(vec![ + Radio { + value: "DX9".into(), + disabled_when: None, + }, + Radio { + value: "DX11".into(), + disabled_when: None, + }, + ]), + checkboxes: None, + }]); + let save = make_save(&[("Renderer", SaveValue::Single("dx9".into()))]); + assert!(SaveValidator::validate(&save, &config).is_ok()); + } + + #[test] + fn valid_when_checkbox_value_differs_only_in_case() { + let config = make_config(vec![OptionGroup { + name: "Mods".into(), + kind: OptionGroupType::CheckboxGroup, + radios: None, + checkboxes: Some(vec![ + Checkbox { + value: "ModA".into(), + disabled_when: None, + }, + Checkbox { + value: "ModB".into(), + disabled_when: None, + }, + ]), + }]); + let save = make_save(&[( + "Mods", + SaveValue::Multiple(vec!["moda".into(), "MODB".into()]), + )]); + assert!(SaveValidator::validate(&save, &config).is_ok()); + } + + #[test] + fn err_for_wrong_types() { + let config = make_config(vec![ + OptionGroup { + name: "Renderer".into(), + kind: OptionGroupType::RadioGroup, + radios: Some(vec![Radio { + value: "DX9".into(), + disabled_when: None, + }]), + checkboxes: None, + }, + OptionGroup { + name: "Mods".into(), + kind: OptionGroupType::CheckboxGroup, + radios: None, + checkboxes: Some(vec![Checkbox { + value: "A".into(), + disabled_when: None, + }]), + }, + ]); + + // Multiple for radioGroup → error + let save1 = make_save(&[("Renderer", SaveValue::Multiple(vec!["DX9".into()]))]); + assert!(SaveValidator::validate(&save1, &config).is_err()); + + // Single for checkboxGroup → error + let save2 = make_save(&[("Mods", SaveValue::Single("A".into()))]); + assert!(SaveValidator::validate(&save2, &config).is_err()); + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..180e528 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,351 @@ +extern crate native_windows_derive as nwd; +extern crate native_windows_gui as nwg; + +pub mod chrome; +pub mod controls; +pub mod error; +use controls::{ + CheckItem, Group, RadioItem, apply_constraints, collect_selections_for_save, + is_config_complete, load_saved_state, +}; + +use nwd::NwgUi; +use nwg::NativeUi; +use std::cell::RefCell; +use std::rc::Rc; + +use crate::{config::model::OptionGroupType, context::AppContext, save::saver::SaveSaver}; +use windows_sys::Win32::UI::Controls::SetWindowTheme; +use windows_sys::Win32::UI::Input::KeyboardAndMouse::EnableWindow; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN, SendMessageW, +}; + +// Layout constants (pixels) +const WINDOW_W: i32 = 420; +const MARGIN: i32 = 10; +const COMBO_H: i32 = 25; +const GROUP_TITLE_H: i32 = 22; +const ITEM_H: i32 = 22; +const GROUP_PADDING: i32 = 6; // bottom padding inside frame +const GROUP_GAP: i32 = 5; // gap between groups +const BTN_H: i32 = 35; + +const WM_SETFONT: u32 = 0x0030; + +#[derive(Default, NwgUi)] +pub struct MulderApp { + #[nwg_control(size: (420, 100), position: (0, 0), title: "MulderConfig", flags: "WINDOW")] + #[nwg_events(OnWindowClose: [MulderApp::on_close])] + window: nwg::Window, + + #[nwg_control(size: (400, 25), position: (10, 10))] + addon_combo: nwg::ComboBox, + + #[nwg_control(text: "Save", size: (195, 35), position: (10, 45))] + save_button: nwg::Button, + + #[nwg_control(text: "Apply", size: (195, 35), position: (215, 45))] + apply_button: nwg::Button, +} + +impl MulderApp { + fn on_close(&self) { + nwg::stop_thread_dispatch(); + } +} + +pub fn run(ctx: AppContext) { + nwg::init().expect("Failed to init Native Windows GUI"); + + let fonts = chrome::create_fonts(); + + let app = MulderApp::build_ui(Default::default()).expect("Failed to build UI"); + app.window.set_text(&ctx.config.game.title); + chrome::apply_icon(&app.window); + + // --- Addon combo --- + let has_addons = ctx.config.addons.as_ref().is_some_and(|a| !a.is_empty()); + let mut y: i32 = MARGIN; + + let mut addon_titles: Vec = vec![ctx.config.game.title.clone()]; + if let Some(addons) = &ctx.config.addons { + addon_titles.extend(addons.iter().map(|a| a.title.clone())); + } + + if has_addons { + for title in &addon_titles { + app.addon_combo.push(title.clone()); + } + let initial_idx = ctx + .config + .addons + .as_deref() + .unwrap_or(&[]) + .iter() + .position(|a| a.title == ctx.active_title) + .map(|i| i + 1) + .unwrap_or(0); + app.addon_combo.set_selection(Some(initial_idx)); + y += COMBO_H + MARGIN; + } else { + app.addon_combo.set_visible(false); + } + + // --- Option groups --- + let (groups_vec, frames, _group_titles, y) = + build_option_groups(&ctx.config.option_groups, &app.window, y, &fonts); + let groups: Rc>> = Rc::new(RefCell::new(groups_vec)); + + // --- Bind event handlers --- + // WM_COMMAND (BN_CLICKED) goes from each radio/checkbox to its direct parent (the Frame). + // So we subclass each Frame, not the individual controls. + let frame_handles: Vec = frames.iter().map(|f| f.handle).collect(); + let window_handle = app.window.handle; + + // Wrap AppContext in Rc for shared mutation across closures + let ctx_rc: Rc> = Rc::new(RefCell::new(ctx)); + + let save_hwnd: isize = match app.save_button.handle { + nwg::ControlHandle::Hwnd(h) => h as isize, + _ => 0, + }; + let apply_hwnd: isize = match app.apply_button.handle { + nwg::ControlHandle::Hwnd(h) => h as isize, + _ => 0, + }; + let btn_handles = Rc::new((save_hwnd, apply_hwnd)); + + // Frame handlers: radio/checkbox clicks (BN_CLICKED goes to direct parent = Frame) + let _handlers: Vec = frame_handles + .iter() + .map(|handle| { + let gc = Rc::clone(&groups); + let cx = Rc::clone(&ctx_rc); + let bh = Rc::clone(&btn_handles); + nwg::bind_event_handler(handle, &window_handle, move |evt, _, _| { + if evt == nwg::Event::OnButtonClick { + apply_constraints(&cx.borrow().active_title, &gc.borrow()); + let ok = i32::from(is_config_complete(&gc.borrow())); + unsafe { + EnableWindow(bh.0 as _, ok); + EnableWindow(bh.1 as _, ok); + } + } + }) + }) + .collect(); + + let save_handle = app.save_button.handle; + let apply_handle = app.apply_button.handle; + + // Window-level handler: combo selection + save button click + const CB_GETCURSEL: u32 = 0x0147; + let combo_hwnd: *mut std::ffi::c_void = match app.addon_combo.handle { + nwg::ControlHandle::Hwnd(h) => h as *mut std::ffi::c_void, + _ => std::ptr::null_mut(), + }; + let addon_titles = Rc::new(addon_titles); + { + let gc = Rc::clone(&groups); + let cx = Rc::clone(&ctx_rc); + let bh = Rc::clone(&btn_handles); + let at = Rc::clone(&addon_titles); + let _window_handler = + nwg::bind_event_handler(&window_handle, &window_handle, move |evt, _, handle| { + if evt == nwg::Event::OnComboxBoxSelection { + let idx = unsafe { SendMessageW(combo_hwnd, CB_GETCURSEL, 0, 0) } as usize; + if idx < at.len() { + cx.borrow_mut().switch_addon(at[idx].clone()); + } + load_saved_state(&cx.borrow().selections, &gc.borrow()); + apply_constraints(&cx.borrow().active_title, &gc.borrow()); + let ok = i32::from(is_config_complete(&gc.borrow())); + unsafe { + EnableWindow(bh.0 as _, ok); + EnableWindow(bh.1 as _, ok); + } + } + if evt == nwg::Event::OnButtonClick && handle == save_handle { + let selections = collect_selections_for_save(&gc.borrow()); + cx.borrow_mut().save_selections(selections); + let ctx = cx.borrow(); + if let Err(e) = SaveSaver::save(&ctx.save_path, &ctx.save_data) { + nwg::simple_message("Save error", &e); + } else { + nwg::simple_message("Saved", "Configuration saved successfully."); + } + } + if evt == nwg::Event::OnButtonClick && handle == apply_handle { + // Collect current UI state before applying (no disk write) + let selections = collect_selections_for_save(&gc.borrow()); + cx.borrow_mut().save_selections(selections); + let errors = crate::apply::apply(&cx.borrow()); + if errors.is_empty() { + nwg::simple_message("Applied", "Configuration applied successfully."); + } else { + for e in &errors { + crate::ui::error::warn(e); + } + } + } + }); + std::mem::forget(_window_handler); + } + + chrome::apply_theme(&app.window, &frames); + + // Apply constraints and set initial button state + load_saved_state(&ctx_rc.borrow().selections, &groups.borrow()); + apply_constraints(&ctx_rc.borrow().active_title, &groups.borrow()); + let ok = i32::from(is_config_complete(&groups.borrow())); + unsafe { + EnableWindow(save_hwnd as _, ok); + EnableWindow(apply_hwnd as _, ok); + } + + // --- Save / Apply buttons at the bottom --- + let btn_w = (WINDOW_W - MARGIN * 3) / 2; + app.save_button.set_position(MARGIN, y); + app.save_button.set_size(btn_w as u32, BTN_H as u32); + app.apply_button.set_position(MARGIN * 2 + btn_w, y); + app.apply_button.set_size(btn_w as u32, BTN_H as u32); + + let content_bottom = chrome::build_footer(&app.window, y + BTN_H, WINDOW_W, MARGIN, &fonts); + let win_h = content_bottom + MARGIN; + app.window.set_size(WINDOW_W as u32, win_h as u32); + + // Center on screen + let screen_w = unsafe { GetSystemMetrics(SM_CXSCREEN) }; + let screen_h = unsafe { GetSystemMetrics(SM_CYSCREEN) }; + app.window + .set_position((screen_w - WINDOW_W) / 2, ((screen_h - win_h) / 2).max(0)); + app.window.set_visible(true); + + nwg::dispatch_thread_events(); +} + +fn build_option_groups( + group_defs: &[crate::config::model::OptionGroup], + window: &nwg::Window, + y_start: i32, + fonts: &chrome::AppFonts, +) -> (Vec, Vec, Vec, i32) { + let mut groups: Vec = Vec::new(); + let mut frames: Vec = Vec::new(); + let mut group_titles: Vec = Vec::new(); + let mut y = y_start; + let inner_w = WINDOW_W - MARGIN * 2 - 8; + + for group_def in group_defs { + let item_count = match &group_def.kind { + OptionGroupType::RadioGroup => { + group_def.radios.as_ref().map_or(0, |v: &Vec<_>| v.len()) + } + OptionGroupType::CheckboxGroup => group_def + .checkboxes + .as_ref() + .map_or(0, |v: &Vec<_>| v.len()), + } as i32; + + let frame_h = GROUP_TITLE_H + item_count * ITEM_H + GROUP_PADDING; + + let mut frame = nwg::Frame::default(); + nwg::Frame::builder() + .size((WINDOW_W - MARGIN * 2, frame_h)) + .position((MARGIN, y)) + .flags(nwg::FrameFlags::VISIBLE | nwg::FrameFlags::BORDER) + .parent(window) + .build(&mut frame) + .expect("Failed to build Frame"); + + let mut title = nwg::Label::default(); + nwg::Label::builder() + .text(&group_def.name) + .size((inner_w, 18)) + .position((4, 2)) + .parent(&frame) + .build(&mut title) + .expect("Failed to build group title"); + if let nwg::ControlHandle::Hwnd(h) = title.handle { + unsafe { + SendMessageW(h as _, WM_SETFONT, fonts.bold as usize, 1); + } + } + + let mut item_y = GROUP_TITLE_H; + + match &group_def.kind { + OptionGroupType::RadioGroup => { + let mut items: Vec = Vec::new(); + for def in group_def.radios.as_deref().unwrap_or(&[]) { + let mut radio = nwg::RadioButton::default(); + nwg::RadioButton::builder() + .text(&def.value) + .size((inner_w, 20)) + .position((5, item_y)) + .parent(&frame) + .build(&mut radio) + .expect("Failed to build RadioButton"); + if let nwg::ControlHandle::Hwnd(h) = radio.handle { + let empty: [u16; 1] = [0]; + unsafe { + SetWindowTheme(h as _, empty.as_ptr(), empty.as_ptr()); + } + unsafe { + SendMessageW(h as _, WM_SETFONT, fonts.normal as usize, 1); + } + } + item_y += ITEM_H; + items.push(RadioItem { + value: def.value.clone(), + disabled_when: def.disabled_when.clone(), + ctrl: radio, + }); + } + groups.push(Group::Radios { + name: group_def.name.clone(), + items, + }); + } + OptionGroupType::CheckboxGroup => { + let mut items: Vec = Vec::new(); + for def in group_def.checkboxes.as_deref().unwrap_or(&[]) { + let mut cb = nwg::CheckBox::default(); + nwg::CheckBox::builder() + .text(&def.value) + .size((inner_w, 20)) + .position((5, item_y)) + .parent(&frame) + .build(&mut cb) + .expect("Failed to build CheckBox"); + if let nwg::ControlHandle::Hwnd(h) = cb.handle { + let empty: [u16; 1] = [0]; + unsafe { + SetWindowTheme(h as _, empty.as_ptr(), empty.as_ptr()); + } + unsafe { + SendMessageW(h as _, WM_SETFONT, fonts.normal as usize, 1); + } + } + item_y += ITEM_H; + items.push(CheckItem { + value: def.value.clone(), + disabled_when: def.disabled_when.clone(), + ctrl: cb, + }); + } + groups.push(Group::Checks { + name: group_def.name.clone(), + items, + }); + } + } + + y += frame_h + GROUP_GAP; + group_titles.push(title); + frames.push(frame); + } + + (groups, frames, group_titles, y) +} diff --git a/src/ui/chrome.rs b/src/ui/chrome.rs new file mode 100644 index 0000000..36f4fb0 --- /dev/null +++ b/src/ui/chrome.rs @@ -0,0 +1,281 @@ +use native_windows_gui as nwg; +use windows_sys::Win32::Foundation::RECT; +use windows_sys::Win32::Graphics::Gdi::{ + CreateFontIndirectW, CreateSolidBrush, DeleteObject, FillRect, SetBkMode, SetTextColor, +}; +use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; +use windows_sys::Win32::UI::Input::KeyboardAndMouse::IsWindowEnabled; +use windows_sys::Win32::UI::Shell::ShellExecuteW; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, GetClassNameW, GetClientRect, LoadCursorW, LoadIconW, NONCLIENTMETRICSW, + SendMessageW, SetCursor, SystemParametersInfoW, WM_SETICON, WS_CHILD, WS_VISIBLE, +}; + +// Color scheme (Steam-like) +pub const COLOR_BG: u32 = 0x002E2825; // #25282E +pub const COLOR_BG_DARK: u32 = 0x002E2623; // #23262E +pub const COLOR_BG_LIGHT: u32 = 0x003F3530; // #30353F +pub const COLOR_TEXT_TITLES: u32 = 0x00DFDED1; // #D1DEDF — group titles +pub const COLOR_TEXT_LABELS: u32 = 0x00EDECEC; // #ECECED — radio/checkbox labels + +const WM_ERASEBKGND: u32 = 0x0014; +const WM_CTLCOLORSTATIC: u32 = 0x0138; +const WM_SETFONT: u32 = 0x0030; +const WM_LBUTTONUP: u32 = 0x0202; +const WM_SETCURSOR: u32 = 0x0020; +const SS_ETCHEDHORZ: u32 = 0x0010; +const SPI_GETNONCLIENTMETRICS: u32 = 0x0029; + +const FOOTER_LABEL_H: i32 = 12; +const FOOTER_LINK_H: i32 = 12; +const FOOTER_GAP: i32 = 4; + +// --- Fonts --- + +pub struct AppFonts { + pub normal: isize, + pub bold: isize, + pub small: isize, + pub link: isize, +} + +impl Drop for AppFonts { + fn drop(&mut self) { + unsafe { + DeleteObject(self.normal as *mut _); + DeleteObject(self.bold as *mut _); + DeleteObject(self.small as *mut _); + DeleteObject(self.link as *mut _); + } + } +} + +pub fn create_fonts() -> AppFonts { + unsafe { + let mut ncm: NONCLIENTMETRICSW = std::mem::zeroed(); + ncm.cbSize = std::mem::size_of::() as u32; + SystemParametersInfoW( + SPI_GETNONCLIENTMETRICS, + ncm.cbSize, + &mut ncm as *mut _ as *mut _, + 0, + ); + let lf_normal = ncm.lfMessageFont; + let mut lf_bold = lf_normal; + lf_bold.lfWeight = 700; + let mut lf_small = lf_normal; + lf_small.lfHeight = lf_normal.lfHeight + 2; // less negative = smaller + let mut lf_link = lf_small; + lf_link.lfUnderline = 1; + AppFonts { + normal: CreateFontIndirectW(&lf_normal) as isize, + bold: CreateFontIndirectW(&lf_bold) as isize, + small: CreateFontIndirectW(&lf_small) as isize, + link: CreateFontIndirectW(&lf_link) as isize, + } + } +} + +// --- Icon --- + +pub fn apply_icon(window: &nwg::Window) { + if let nwg::ControlHandle::Hwnd(hwnd) = window.handle { + #[allow(clippy::manual_dangling_ptr)] + // 1 is a resource ID (MAKEINTRESOURCE), not a real pointer + let hicon = unsafe { LoadIconW(GetModuleHandleW(std::ptr::null()), 1 as *const u16) }; + if !hicon.is_null() { + unsafe { + SendMessageW(hwnd as _, WM_SETICON, 1, hicon as _); // ICON_BIG + SendMessageW(hwnd as _, WM_SETICON, 0, hicon as _); // ICON_SMALL + } + } + } +} + +// --- Theming --- + +pub fn apply_theme(window: &nwg::Window, frames: &[nwg::Frame]) { + let brush_dark = unsafe { CreateSolidBrush(COLOR_BG_DARK) } as isize; + let brush_bg = unsafe { CreateSolidBrush(COLOR_BG) } as isize; + let brush_light = unsafe { CreateSolidBrush(COLOR_BG_LIGHT) } as isize; + + // Window background + static labels + let _theme_win = + nwg::bind_raw_event_handler(&window.handle, 0x10001, move |hwnd, msg, w, _| { + if msg == WM_ERASEBKGND { + let hdc = w as *mut std::ffi::c_void; + let mut rect: RECT = unsafe { std::mem::zeroed() }; + unsafe { + GetClientRect(hwnd as usize as *mut std::ffi::c_void, &mut rect); + } + unsafe { + FillRect(hdc, &rect, brush_dark as usize as *mut std::ffi::c_void); + } + return Some(1); + } + if msg == WM_CTLCOLORSTATIC { + let hdc = w as *mut std::ffi::c_void; + unsafe { + SetTextColor(hdc, COLOR_TEXT_TITLES); + SetBkMode(hdc, 1); + } + return Some(brush_bg); + } + None + }) + .ok(); + let _ = _theme_win; // RawEventHandler has no Drop — handler stays registered at OS level + + // Frame backgrounds + radio/checkbox colors + let _theme_frames: Vec<_> = + frames + .iter() + .enumerate() + .map(|(i, frame)| { + nwg::bind_raw_event_handler(&frame.handle, 0x10002 + i, move |hwnd, msg, w, l| { + match msg { + WM_ERASEBKGND => { + let hdc = w as *mut std::ffi::c_void; + let mut rect: RECT = unsafe { std::mem::zeroed() }; + unsafe { + GetClientRect(hwnd as usize as *mut std::ffi::c_void, &mut rect); + } + unsafe { + FillRect(hdc, &rect, brush_light as usize as *mut std::ffi::c_void); + } + Some(1) + } + WM_CTLCOLORSTATIC => { + let hdc = w as *mut std::ffi::c_void; + let ctrl_hwnd = l as usize as *mut std::ffi::c_void; + let is_enabled = unsafe { IsWindowEnabled(ctrl_hwnd) } != 0; + unsafe { + SetBkMode(hdc, 1); + } + if is_enabled { + let mut class: [u16; 16] = [0; 16]; + let n = unsafe { GetClassNameW(ctrl_hwnd, class.as_mut_ptr(), 16) }; + let is_button = n > 0 && class[0] == b'B' as u16; + let color = if is_button { + COLOR_TEXT_LABELS + } else { + COLOR_TEXT_TITLES + }; + unsafe { + SetTextColor(hdc, color); + } + } + Some(brush_light) + } + _ => None, + } + }) + .ok() + }) + .collect(); + std::mem::forget(_theme_frames); +} + +// --- Footer --- + +/// Builds the separator + footer labels below the buttons. +/// `y_after_btns` = y + BTN_H (top of the footer zone). +/// Returns `content_bottom` — add MARGIN to get `win_h`. +pub fn build_footer( + window: &nwg::Window, + y_after_btns: i32, + window_w: i32, + margin: i32, + fonts: &AppFonts, +) -> i32 { + let y_sep = y_after_btns + 6; + let y_footer = y_sep + 8; + + // Horizontal separator line + let sep_class: Vec = "STATIC\0".encode_utf16().collect(); + let win_hwnd = match window.handle { + nwg::ControlHandle::Hwnd(h) => h as *mut std::ffi::c_void, + _ => std::ptr::null_mut(), + }; + unsafe { + CreateWindowExW( + 0, + sep_class.as_ptr(), + std::ptr::null(), + WS_CHILD | WS_VISIBLE | SS_ETCHEDHORZ, + margin, + y_sep, + window_w - margin * 2 + 2, + 2, + win_hwnd, + std::ptr::null_mut(), + GetModuleHandleW(std::ptr::null()), + std::ptr::null(), + ); + } + + let mut foot_text = nwg::Label::default(); + nwg::Label::builder() + .text("MulderConfig is part of the Mulderland project") + .size((window_w - margin * 2, FOOTER_LABEL_H)) + .position((margin, y_footer)) + .h_align(nwg::HTextAlign::Center) + .parent(window) + .build(&mut foot_text) + .expect("Failed to build footer label"); + if let nwg::ControlHandle::Hwnd(h) = foot_text.handle { + unsafe { + SendMessageW(h as _, WM_SETFONT, fonts.small as usize, 1); + } + } + + let mut foot_link = nwg::Label::default(); + nwg::Label::builder() + .text("www.mulderland.com") + .size((window_w - margin * 2, FOOTER_LINK_H)) + .position((margin, y_footer + FOOTER_LABEL_H + FOOTER_GAP)) + .h_align(nwg::HTextAlign::Center) + .parent(window) + .build(&mut foot_link) + .expect("Failed to build footer link"); + if let nwg::ControlHandle::Hwnd(h) = foot_link.handle { + unsafe { + SendMessageW(h as _, WM_SETFONT, fonts.link as usize, 1); + } + } + + let _foot_link_handler = + nwg::bind_raw_event_handler(&foot_link.handle, 0x10011, move |_, msg, _, _| match msg { + WM_LBUTTONUP => { + let url: Vec = "https://www.mulderland.com?utm_source=MulderConfig\0" + .encode_utf16() + .collect(); + let op: Vec = "open\0".encode_utf16().collect(); + unsafe { + ShellExecuteW( + std::ptr::null_mut(), + op.as_ptr(), + url.as_ptr(), + std::ptr::null(), + std::ptr::null(), + 1, + ); + } + None + } + WM_SETCURSOR => { + let hcur = unsafe { LoadCursorW(std::ptr::null_mut(), 32649 as *const u16) }; + unsafe { + SetCursor(hcur); + } + Some(1) + } + _ => None, + }); + // Keep controls and handler alive for the duration of the program + std::mem::forget(_foot_link_handler); + std::mem::forget(foot_text); + std::mem::forget(foot_link); + + y_footer + FOOTER_LABEL_H + FOOTER_GAP + FOOTER_LINK_H +} diff --git a/src/ui/controls.rs b/src/ui/controls.rs new file mode 100644 index 0000000..cdf8628 --- /dev/null +++ b/src/ui/controls.rs @@ -0,0 +1,165 @@ +use crate::{ + config::model::WhenGroup, + config::when_resolver::{SelectionValue, Selections, match_when}, + save::{GroupSelections, SaveValue}, +}; +use indexmap::IndexMap; +use native_windows_gui as nwg; + +pub struct RadioItem { + pub value: String, + pub disabled_when: Option>, + pub ctrl: nwg::RadioButton, +} + +pub struct CheckItem { + pub value: String, + pub disabled_when: Option>, + pub ctrl: nwg::CheckBox, +} + +pub enum Group { + Radios { name: String, items: Vec }, + Checks { name: String, items: Vec }, +} + +pub fn load_saved_state(selections: &GroupSelections, groups: &[Group]) { + for group in groups { + match group { + Group::Radios { name, items } => { + for item in items { + item.ctrl.set_check_state(nwg::RadioButtonState::Unchecked); + } + if let Some(SaveValue::Single(val)) = selections.get(name) { + for item in items { + if item.value.eq_ignore_ascii_case(val) { + item.ctrl.set_check_state(nwg::RadioButtonState::Checked); + break; + } + } + } + } + Group::Checks { name, items } => { + let checked_vals: Vec<&str> = selections + .get(name) + .and_then(|v| { + if let SaveValue::Multiple(list) = v { + Some(list.iter().map(|s| s.as_str()).collect()) + } else { + None + } + }) + .unwrap_or_default(); + for item in items { + let state = if checked_vals + .iter() + .any(|v| v.eq_ignore_ascii_case(&item.value)) + { + nwg::CheckBoxState::Checked + } else { + nwg::CheckBoxState::Unchecked + }; + item.ctrl.set_check_state(state); + } + } + } + } +} + +pub fn is_config_complete(groups: &[Group]) -> bool { + groups.iter().all(|g| match g { + Group::Radios { items, .. } => items + .iter() + .any(|i| i.ctrl.check_state() == nwg::RadioButtonState::Checked), + Group::Checks { .. } => true, + }) +} + +pub fn collect_selections_for_save(groups: &[Group]) -> GroupSelections { + let mut map = IndexMap::new(); + for group in groups { + match group { + Group::Radios { name, items } => { + if let Some(item) = items + .iter() + .find(|i| i.ctrl.check_state() == nwg::RadioButtonState::Checked) + { + map.insert(name.clone(), SaveValue::Single(item.value.clone())); + } + } + Group::Checks { name, items } => { + let checked: Vec = items + .iter() + .filter(|i| i.ctrl.check_state() == nwg::CheckBoxState::Checked) + .map(|i| i.value.clone()) + .collect(); + map.insert(name.clone(), SaveValue::Multiple(checked)); + } + } + } + map +} + +pub fn apply_constraints(title: &str, groups: &[Group]) { + // 1. Build current selections from control states + let mut selections: Selections = Selections::new(); + selections.insert( + "Title".to_string(), + SelectionValue::Single(title.to_string()), + ); + for group in groups { + match group { + Group::Radios { name, items } => { + if let Some(item) = items + .iter() + .find(|i| i.ctrl.check_state() == nwg::RadioButtonState::Checked) + { + selections.insert(name.clone(), SelectionValue::Single(item.value.clone())); + } + } + Group::Checks { name, items } => { + let checked: Vec = items + .iter() + .filter(|i| i.ctrl.check_state() == nwg::CheckBoxState::Checked) + .map(|i| i.value.clone()) + .collect(); + selections.insert(name.clone(), SelectionValue::Multiple(checked)); + } + } + } + + // 2. Apply disabled_when to each item, updating selections in place + // so cascading constraints (e.g. MSAA depends on dgVoodoo2) work correctly. + for group in groups { + match group { + Group::Radios { name, items } => { + for item in items { + if let Some(when) = &item.disabled_when { + let should_disable = match_when(when, &selections); + item.ctrl.set_enabled(!should_disable); + if should_disable { + item.ctrl.set_check_state(nwg::RadioButtonState::Unchecked); + // Remove from selections so downstream constraints see the updated state + selections.remove(name); + } + } + } + } + Group::Checks { name, items } => { + for item in items { + if let Some(when) = &item.disabled_when { + let should_disable = match_when(when, &selections); + item.ctrl.set_enabled(!should_disable); + if should_disable { + item.ctrl.set_check_state(nwg::CheckBoxState::Unchecked); + // Remove this value from the Multiple list in selections + if let Some(SelectionValue::Multiple(list)) = selections.get_mut(name) { + list.retain(|v| !v.eq_ignore_ascii_case(&item.value)); + } + } + } + } + } + } + } +} diff --git a/src/ui/error.rs b/src/ui/error.rs new file mode 100644 index 0000000..498b1c9 --- /dev/null +++ b/src/ui/error.rs @@ -0,0 +1,46 @@ +use windows_sys::Win32::UI::WindowsAndMessaging::{ + IDYES, MB_ICONERROR, MB_ICONWARNING, MB_OK, MB_YESNO, MessageBoxW, +}; + +pub fn warn(msg: &str) { + let title: Vec = "MulderConfig\0".encode_utf16().collect(); + let text: Vec = format!("{msg}\0").encode_utf16().collect(); + unsafe { + MessageBoxW( + std::ptr::null_mut(), + text.as_ptr(), + title.as_ptr(), + MB_OK | MB_ICONWARNING, + ); + } +} + +pub fn fatal(msg: &str) -> ! { + let title: Vec = "MulderConfig\0".encode_utf16().collect(); + let text: Vec = format!("{msg}\0").encode_utf16().collect(); + unsafe { + MessageBoxW( + std::ptr::null_mut(), + text.as_ptr(), + title.as_ptr(), + MB_OK | MB_ICONERROR, + ); + } + std::process::exit(1); +} + +/// Shows a warning with "Yes" = delete save / "No" = cancel (exit). +/// Returns true if the user chose to delete the save. +pub fn ask_delete_save(msg: &str) -> bool { + let title: Vec = "MulderConfig\0".encode_utf16().collect(); + let text: Vec = format!("{msg}\0").encode_utf16().collect(); + let result = unsafe { + MessageBoxW( + std::ptr::null_mut(), + text.as_ptr(), + title.as_ptr(), + MB_YESNO | MB_ICONWARNING, + ) + }; + result == IDYES +} diff --git a/tests/.gitignore b/tests/.gitignore deleted file mode 100644 index cbbd0b5..0000000 --- a/tests/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -bin/ -obj/ \ No newline at end of file diff --git a/tests/Actions/FileOperationManagerTests.cs b/tests/Actions/FileOperationManagerTests.cs deleted file mode 100644 index e2e356e..0000000 --- a/tests/Actions/FileOperationManagerTests.cs +++ /dev/null @@ -1,50 +0,0 @@ -#nullable enable - -using System; -using System.Collections.Generic; -using System.IO; -using MulderConfig.Actions; -using MulderConfig.Configuration; -using Xunit; - -namespace MulderConfigTests.Actions; - -public class FileOperationManagerTests -{ - [Fact] - public void ExecuteOperations_Move_MovesDirectory() - { - var root = Path.Combine(Path.GetTempPath(), "MulderConfigTests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(root); - - var sourceDir = Path.Combine(root, "srcDir"); - var targetDir = Path.Combine(root, "dstDir"); - Directory.CreateDirectory(sourceDir); - File.WriteAllText(Path.Combine(sourceDir, "a.txt"), "hello"); - - try - { - var mgr = new FileOperationManager(); - mgr.ExecuteOperations( - new List - { - new() - { - Operation = "move", - Source = sourceDir, - Target = targetDir - } - }, - selected: new Dictionary()); - - Assert.False(Directory.Exists(sourceDir)); - Assert.True(Directory.Exists(targetDir)); - Assert.True(File.Exists(Path.Combine(targetDir, "a.txt"))); - } - finally - { - if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); - } - } -} diff --git a/tests/Actions/LaunchManagerTests.cs b/tests/Actions/LaunchManagerTests.cs deleted file mode 100644 index 63306e8..0000000 --- a/tests/Actions/LaunchManagerTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -#nullable enable - -using System; -using System.Collections.Generic; -using MulderConfig; -using MulderConfig.Actions; -using MulderConfig.Configuration; -using Newtonsoft.Json; -using Xunit; - -namespace MulderConfigTests.Actions; - -public class LaunchManagerTests -{ - private static ConfigModel ParseConfig(string json) - => JsonConvert.DeserializeObject(json)!; - - [Fact] - public void ResolveLaunch_ReturnsDefaults_WhenNoRuleMatches() - { - var json = @" - { - ""game"": { ""title"": ""Test"", ""originalExe"": ""Game.exe"" }, - ""optionGroups"": [], - ""actions"": { - ""launch"": [ - { - ""when"": [ { ""Renderer"": ""DX9"" } ], - ""exec"": { ""name"": ""dx9.exe"", ""workDir"": "".\\"" }, - ""args"": [""-a""] - } - ], - ""operations"": [] - } - }"; - - var config = ParseConfig(json); - - var manager = new LaunchManager( - config, - title: "default", - choices: new Dictionary { ["Renderer"] = "DX11" }); - - var (exePath, workDir, _, args) = manager.ResolveLaunch(); - - Assert.Equal(System.IO.Path.Combine(System.Windows.Forms.Application.StartupPath, "Game.exe"), exePath); - Assert.Equal(System.Windows.Forms.Application.StartupPath, workDir); - Assert.Equal(string.Empty, args); - } - - [Fact] - public void ResolveLaunch_AppendsArgs_And_LastExecWins() - { - var json = @" - { - ""game"": { ""title"": ""Test"", ""originalExe"": ""Game.exe"" }, - ""optionGroups"": [], - ""actions"": { - ""launch"": [ - { - ""when"": [ { ""Renderer"": ""DX9"" } ], - ""exec"": { ""name"": ""dx9.exe"", ""workDir"": "".\\"" }, - ""args"": [""-nosetup""] - }, - { - ""when"": [ { ""Renderer"": ""DX9"" } ], - ""exec"": { ""name"": ""dx9_alt.exe"", ""workDir"": ""C:\\tmp"" }, - ""args"": [""-novsync"", ""-borderless""] - } - ], - ""operations"": [] - } - }"; - - var config = ParseConfig(json); - - var manager = new LaunchManager( - config, - title: "default", - choices: new Dictionary { ["Renderer"] = "DX9" }); - - var (exePath, workDir, _, args) = manager.ResolveLaunch(); - - var expectedExePath = System.IO.Path.GetFullPath( - System.IO.Path.Combine(System.Windows.Forms.Application.StartupPath, "dx9_alt.exe")); - - Assert.Equal(expectedExePath, exePath); - Assert.Equal(System.IO.Path.GetFullPath("C:\\tmp"), workDir); - Assert.Equal("-nosetup -novsync -borderless", args); - } -} diff --git a/tests/Configuration/ConfigJsonTests.cs b/tests/Configuration/ConfigJsonTests.cs deleted file mode 100644 index 0eb38b7..0000000 --- a/tests/Configuration/ConfigJsonTests.cs +++ /dev/null @@ -1,244 +0,0 @@ -using System.Collections.Generic; -using MulderConfig.Logic; -using MulderConfig.Configuration; -using Newtonsoft.Json; -using Xunit; - -namespace MulderConfigTests.Configuration; - -public class ConfigJsonTests -{ - private static ConfigModel ParseConfig(string json) - { - return JsonConvert.DeserializeObject(json)!; - } - - private static Dictionary Sel(params (string key, object val)[] items) - { - var d = new Dictionary(); - foreach (var (k, v) in items) d[k] = v; - return d; - } - - private static (string exe, string workDir, string args) ResolveLaunchLikeLaunchManager(ConfigModel config, Dictionary selected) - { - // This mirrors the intended semantics: - // - traverse actions.launch in JSON order - // - args are cumulative (append) - // - exec is atomic and last match wins - var exe = config.Game.OriginalExe; - var workDir = ".\\"; - var args = new List(); - - foreach (var rule in config.Actions.Launch) - { - if (!WhenResolver.Match(rule.When, selected)) - continue; - - if (rule.Exec != null) - { - exe = rule.Exec.Name; - workDir = rule.Exec.WorkDir; - } - - if (rule.Args != null) - args.AddRange(rule.Args); - } - - return (exe, workDir, string.Join(" ", args)); - } - - [Fact] - public void PartialJson_DeserializesLaunchAndOperations() - { - var json = @" - { - ""game"": { ""title"": ""Test Game"", ""originalExe"": ""Game.exe"" }, - ""optionGroups"": [], - ""actions"": { - ""launch"": [ - { - ""when"": [ { ""Renderer"": ""DX9"" } ], - ""exec"": { ""name"": ""dx9.exe"", ""workDir"": "".\\"" }, - ""args"": [""-a""] - } - ], - ""operations"": [ - { - ""when"": [ { ""Renderer"": ""DX9"" } ], - ""operation"": ""rename"", - ""source"": ""a.dll"", - ""target"": ""b.dll"" - }, - { - ""operation"": ""replaceLine"", - ""files"": [""FalloutPrefs.ini""], - ""pattern"": ""^iSize W=.*$"", - ""replacement"": ""iSize W=1920"" - } - ] - } - }"; - - var config = ParseConfig(json); - - Assert.Equal("Test Game", config.Game.Title); - Assert.Equal("Game.exe", config.Game.OriginalExe); - - Assert.Single(config.Actions.Launch); - Assert.NotNull(config.Actions.Launch[0].Exec); - Assert.Equal("dx9.exe", config.Actions.Launch[0].Exec!.Name); - Assert.Equal(@".\", config.Actions.Launch[0].Exec!.WorkDir); - Assert.Equal(new[] { "-a" }, config.Actions.Launch[0].Args); - - Assert.Equal(2, config.Actions.Operations.Count); - Assert.Equal("rename", config.Actions.Operations[0].Operation); - Assert.Equal("a.dll", config.Actions.Operations[0].Source); - Assert.Equal("b.dll", config.Actions.Operations[0].Target); - - Assert.Equal("replaceLine", config.Actions.Operations[1].Operation); - Assert.Equal(new[] { "FalloutPrefs.ini" }, config.Actions.Operations[1].Files); - } - - [Fact] - public void LaunchRules_ArgsAppend_And_LastExecWins() - { - var json = @" - { - ""game"": { ""title"": ""Test"", ""originalExe"": ""Game.exe"" }, - ""optionGroups"": [], - ""actions"": { - ""launch"": [ - { - ""when"": [ { ""Renderer"": ""DX9"" } ], - ""exec"": { ""name"": ""dx9.exe"", ""workDir"": "".\\"" }, - ""args"": [""-nosetup""] - }, - { - ""when"": [ { ""Renderer"": ""DX9"" } ], - ""exec"": { ""name"": ""dx9_alt.exe"", ""workDir"": ""C:\\tmp"" }, - ""args"": [""-novsync"", ""-borderless""] - } - ], - ""operations"": [] - } - }"; - - var config = ParseConfig(json); - var selected = Sel(("Renderer", "DX9")); - - var (exe, workDir, args) = ResolveLaunchLikeLaunchManager(config, selected); - - Assert.Equal("dx9_alt.exe", exe); - Assert.Equal(@"C:\tmp", workDir); - Assert.Equal("-nosetup -novsync -borderless", args); - } - - [Fact] - public void NullWhen_IsTreatedAsAlwaysApply() - { - var json = @" - { - ""game"": { ""title"": ""Test"", ""originalExe"": ""Game.exe"" }, - ""optionGroups"": [], - ""actions"": { - ""launch"": [ { ""args"": [""-a""] } ], - ""operations"": [ { ""operation"": ""removeLine"", ""files"": [""a.ini""], ""pattern"": ""^x=.*$"" } ] - } - }"; - - var config = ParseConfig(json); - var selected = Sel(("Renderer", "Anything")); - - Assert.True(WhenResolver.Match(config.Actions.Launch[0].When, selected)); - Assert.True(WhenResolver.Match(config.Actions.Operations[0].When, selected)); - } - - [Fact] - public void MissingLaunchSection_DefaultsToEmpty_AndConfigIsValid() - { - var json = @" - { - ""game"": { ""title"": ""Test"", ""originalExe"": ""Game.exe"" }, - ""addons"": [ { ""title"": ""default"", ""steamId"": 1 } ], - ""optionGroups"": [], - ""actions"": { - ""operations"": [ { ""operation"": ""delete"", ""source"": ""tmp.txt"" } ] - } - }"; - - var config = ParseConfig(json); - - Assert.True(ConfigValidator.IsValid(config)); - Assert.NotNull(config.Actions.Launch); - Assert.Empty(config.Actions.Launch); - Assert.NotNull(config.Actions.Operations); - Assert.Single(config.Actions.Operations); - } - - [Fact] - public void MissingOperationsSection_DefaultsToEmpty_AndConfigIsValid() - { - var json = @" - { - ""game"": { ""title"": ""Test"", ""originalExe"": ""Game.exe"" }, - ""addons"": [ { ""title"": ""default"", ""steamId"": 1 } ], - ""optionGroups"": [], - ""actions"": { - ""launch"": [ { ""args"": [""-a""] } ] - } - }"; - - var config = ParseConfig(json); - - Assert.True(ConfigValidator.IsValid(config)); - Assert.NotNull(config.Actions.Operations); - Assert.Empty(config.Actions.Operations); - Assert.NotNull(config.Actions.Launch); - Assert.Single(config.Actions.Launch); - } - - [Fact] - public void NoActions_IsInvalid() - { - var json = @" - { - ""game"": { ""title"": ""Test"", ""originalExe"": ""Game.exe"" }, - ""addons"": [ { ""title"": ""default"", ""steamId"": 1 } ], - ""optionGroups"": [], - ""actions"": { } - }"; - - var config = ParseConfig(json); - Assert.False(ConfigValidator.IsValid(config)); - } - - [Fact] - public void EmptyLaunchAndOperations_IsInvalid() - { - var json = @" - { - ""game"": { ""title"": ""Test"", ""originalExe"": ""Game.exe"" }, - ""addons"": [ { ""title"": ""default"", ""steamId"": 1 } ], - ""optionGroups"": [], - ""actions"": { ""launch"": [], ""operations"": [] } - }"; - - var config = ParseConfig(json); - Assert.False(ConfigValidator.IsValid(config)); - } - - [Fact] - public void MissingAddons_IsValid() - { - var json = @" - { - ""game"": { ""title"": ""Test"", ""originalExe"": ""Game.exe"" }, - ""optionGroups"": [], - ""actions"": { ""launch"": [ { ""args"": [""-a""] } ] } - }"; - - var config = ParseConfig(json); - Assert.True(ConfigValidator.IsValid(config)); - } -} diff --git a/tests/Configuration/ConfigValidatorTests.cs b/tests/Configuration/ConfigValidatorTests.cs deleted file mode 100644 index 6fffdb4..0000000 --- a/tests/Configuration/ConfigValidatorTests.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System.Collections.Generic; -using MulderConfig.Configuration; -using Xunit; - -namespace MulderConfigTests.Configuration; - -public class ConfigValidatorTests -{ - private static ConfigModel MinimalValidConfig() - { - return new ConfigModel - { - Game = new Game { Title = "Test", OriginalExe = "Game.exe" }, - Addons = new List { new() { Title = "default" } }, - OptionGroups = new List - { - new() - { - Name = "Renderer", - Type = "radioGroup", - Radios = new List { new() { Value = "DX9" } } - } - }, - Actions = new ActionRoot - { - Launch = new List - { - new() - { - Exec = new ExecSpec { Name = "Game.exe", WorkDir = ".\\" }, - Args = new List { "-a" } - } - }, - Operations = new List - { - new() - { - Operation = "delete", - Source = "tmp.txt" - } - } - } - }; - } - - [Fact] - public void IsValid_ReturnsTrue_ForMinimalValidConfig() - { - Assert.True(ConfigValidator.IsValid(MinimalValidConfig())); - } - - [Fact] - public void IsValid_ReturnsTrue_WhenAddonListMissing() - { - var cfg = MinimalValidConfig(); - cfg.Addons = null; - Assert.True(ConfigValidator.IsValid(cfg)); - } - - [Fact] - public void IsValid_ReturnsFalse_ForUnknownGroupType() - { - var cfg = MinimalValidConfig(); - cfg.OptionGroups[0].Type = "dropdown"; - Assert.False(ConfigValidator.IsValid(cfg)); - } - - [Fact] - public void IsValid_ReturnsFalse_WhenRadioGroupHasNoRadios() - { - var cfg = MinimalValidConfig(); - cfg.OptionGroups[0].Radios = new List(); - Assert.False(ConfigValidator.IsValid(cfg)); - } - - [Fact] - public void IsValid_ReturnsFalse_WhenLaunchExecMissingFields() - { - var cfg = MinimalValidConfig(); - cfg.Actions.Launch[0].Exec = new ExecSpec { Name = "", WorkDir = ".\\" }; - Assert.False(ConfigValidator.IsValid(cfg)); - } - - [Fact] - public void IsValid_ReturnsFalse_WhenOperationMissingRequiredFields() - { - var cfg = MinimalValidConfig(); - cfg.Actions.Operations = new List - { - new() - { - Operation = "rename", - Source = "a.dll", - Target = null - } - }; - - Assert.False(ConfigValidator.IsValid(cfg)); - } - - [Fact] - public void IsValid_ReturnsFalse_WhenSetReadOnlyHasNoFiles() - { - var cfg = MinimalValidConfig(); - cfg.Actions.Operations = new List - { - new() - { - Operation = "setReadOnly", - Files = new List() - } - }; - - Assert.False(ConfigValidator.IsValid(cfg)); - } - - [Fact] - public void IsValid_ReturnsTrue_WhenSetReadOnlyHasFiles() - { - var cfg = MinimalValidConfig(); - cfg.Actions.Operations = new List - { - new() - { - Operation = "setReadOnly", - Files = new List { "Fallout.ini" } - } - }; - - Assert.True(ConfigValidator.IsValid(cfg)); - } - -} diff --git a/tests/Logic/WhenResolverTests.cs b/tests/Logic/WhenResolverTests.cs deleted file mode 100644 index 83edcc7..0000000 --- a/tests/Logic/WhenResolverTests.cs +++ /dev/null @@ -1,259 +0,0 @@ -using System.Collections.Generic; -using MulderConfig.Logic; -using MulderConfig.Configuration; -using Newtonsoft.Json.Linq; -using Xunit; - -namespace MulderConfigTests.Logic; - -public class WhenResolverTests -{ - private static List ParseWhen(string json, string prop = "when") - { - var token = JToken.Parse(json)[prop]; - return token!.ToObject>()!; - } - - private static Dictionary Sel(params (string key, object val)[] items) - { - var d = new Dictionary(); - foreach (var (k, v) in items) d[k] = v; - return d; - } - - [Fact] - public void And_AllConditionsMatch_Succeeds() - { - // AND: all conditions must match - var json = @"{ ""when"": [ { ""Renderer"": ""DX9"", ""HDR"": ""Enabled"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Renderer", "DX9"), ("HDR", "Enabled")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void And_OneConditionMiss_Fails() - { - // AND: if one condition does not match => fail - var json = @"{ ""when"": [ { ""Renderer"": ""DX9"", ""HDR"": ""Enabled"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Renderer", "DX11"), ("HDR", "Enabled")); - - Assert.False(WhenResolver.Match(when, selected)); - } - - [Fact] - public void And_NoConditionMatches_Fails() - { - // AND: if all conditions mismatch => fail - var json = @"{ ""when"": [ { ""Renderer"": ""DX9"", ""HDR"": ""Enabled"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Renderer", "DX11"), ("HDR", "Disabled")); - - Assert.False(WhenResolver.Match(when, selected)); - } - - [Fact] - public void Or_AllGroupMatch_Succeeds() - { - // OR: all groupes match => succeeds - var json = @"{ ""when"": [ { ""Resolution"": ""2560x1440"" }, { ""Renderer"": ""DXVK"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Resolution", "2560x1440"), ("Renderer", "DXVK")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void Or_OneGroupMatches_Succeeds() - { - // OR: all groupes match => succeeds - var json = @"{ ""when"": [ { ""Resolution"": ""2560x1440"" }, { ""Renderer"": ""DXVK"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Renderer", "DXVK")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void Or_NoGroupMatches_Fails() - { - // OR: aucun groupe ne matche => false - var json = @"{ ""when"": [ { ""Resolution"": ""2560x1440"" }, { ""Renderer"": ""DXVK"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Resolution", "1920x1080"), ("Renderer", "D3D9")); - - Assert.False(WhenResolver.Match(when, selected)); - } - - [Fact] - public void OrOfAndGroups_MixedExample_Succeeds() - { - // (Resolution contains 1920x AND Renderer == DXVK) OR (FOV Modifier != "None") - var json = @" - { - ""when"": [ - { ""*Resolution"": ""1920x"", ""Renderer"": ""DXVK"" }, - { ""!FOV Modifier"": ""None"" } - ] - }"; - var when = ParseWhen(json); - - // The first group fails (renderer != DXVK), but the second succeeds => OR => true - var selected = Sel(("Resolution", "1920x1080"), ("Renderer", "D3D9"), ("FOV Modifier", "lower")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void NotEquals_Succeeds() - { - var json = @"{ ""when"": [ { ""!Renderer"": ""DXVK"" } ] }"; - - var when = ParseWhen(json); - var selected = Sel(("Renderer", "DX9")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void NotEquals_Fails() - { - var json = @"{ ""when"": [ { ""!Resolution"": ""1920x1080"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Resolution", "1920x1080")); - - Assert.False(WhenResolver.Match(when, selected)); - } - - [Fact] - public void Contains_Succeeds() - { - var json = @"{ ""when"": [ { ""*Resolution"": ""1920x"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Resolution", "1920x1080")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void Contains_Fails() - { - var json = @"{ ""when"": [ { ""*Resolution"": ""2560x"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Resolution", "1920x1080")); - - Assert.False(WhenResolver.Match(when, selected)); - } - - [Fact] - public void NotContains_Succeeds() - { - var json = @"{ ""when"": [ { ""!*Renderer"": ""DXVK"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Renderer", "DX9")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void NotContains_Fails() - { - var json = @"{ ""when"": [ { ""!*Renderer"": ""DXVK"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Renderer", "Vulkan DXVK Wrapper")); - - Assert.False(WhenResolver.Match(when, selected)); - } - - [Fact] - public void EmptyExpected_MatchesNothingSelected() - { - var json = @"{ ""when"": [ { ""Switchable Mods"": """" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Switchable Mods", new List())); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void List_Contains_Succeeds() - { - var json = @"{ ""when"": [ { ""*Switchable Mods"": ""NV"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Switchable Mods", new List { "NVHR", "DXVK" })); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void List_NotContains_Fails() - { - var json = @"{ ""when"": [ { ""!*Switchable Mods"": ""Vulkan"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Switchable Mods", new List { "NVHR", "Vulkan DXVK Wrapper" })); - - Assert.False(WhenResolver.Match(when, selected)); - } - - [Fact] - public void MissingKey_Equals_Fails() - { - var json = @"{ ""when"": [ { ""Renderer"": ""DXVK"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Resolution", "1920x1080")); - - Assert.False(WhenResolver.Match(when, selected)); - } - - [Fact] - public void MissingKey_Contains_Fails() - { - var json = @"{ ""when"": [ { ""*Renderer"": ""DX"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Resolution", "1920x1080")); - - Assert.False(WhenResolver.Match(when, selected)); - } - - [Fact] - public void MissingKey_NotEquals_Succeeds() - { - var json = @"{ ""when"": [ { ""!Renderer"": ""DXVK"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Resolution", "1920x1080")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void MissingKey_NotContains_Succeeds() - { - var json = @"{ ""when"": [ { ""!*Renderer"": ""DXVK"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Resolution", "1920x1080")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void CaseInsensitive_Equals_And_Contains_Work() - { - var json = @"{ ""when"": [ { ""Renderer"": ""dxvk"", ""*Resolution"": ""1920X"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Renderer", "DXVK"), ("Resolution", "1920x1080")); - - Assert.True(WhenResolver.Match(when, selected)); - } - - [Fact] - public void CaseInsensitive_NotEquals_And_NotContains_Work() - { - var json = @"{ ""when"": [ { ""!Renderer"": ""dxvk"", ""!*Resolution"": ""(21/9)"" } ] }"; - var when = ParseWhen(json); - var selected = Sel(("Renderer", "DX9"), ("Resolution", "1920x1080 (16/9)")); - - Assert.True(WhenResolver.Match(when, selected)); - } -} diff --git a/tests/MulderConfigTests.csproj b/tests/MulderConfigTests.csproj deleted file mode 100644 index 8ca2f66..0000000 --- a/tests/MulderConfigTests.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - net8.0-windows - false - - - - - - - - - - - - - \ No newline at end of file diff --git a/tests/Save/SaveValidatorTests.cs b/tests/Save/SaveValidatorTests.cs deleted file mode 100644 index 7ab7af2..0000000 --- a/tests/Save/SaveValidatorTests.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System; -using System.Collections.Generic; -using MulderConfig.Configuration; -using MulderConfig.Save; -using Xunit; - -namespace MulderConfigTests.Save; - -public class SaveValidatorTests -{ - private static ConfigModel MakeConfig(params OptionGroup[] groups) - { - return new ConfigModel - { - Game = new Game { Title = "Test", OriginalExe = "Game.exe" }, - Addons = new List { new() { Title = "default" } }, - OptionGroups = new List(groups), - Actions = new ActionRoot { Launch = new List(), Operations = new List() } - }; - } - - [Fact] - public void IsValid_ReturnsTrue_ForValidRadioChoice() - { - var config = MakeConfig( - new OptionGroup - { - Name = "Renderer", - Type = "radioGroup", - Radios = new List - { - new() { Value = "DX9" }, - new() { Value = "DX11" }, - } - }); - - var saved = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["Renderer"] = "DX9" - }; - - Assert.True(SaveValidator.IsValid(config, saved)); - } - - [Fact] - public void IsValid_ReturnsFalse_WhenGroupDoesNotExist() - { - var config = MakeConfig( - new OptionGroup { Name = "Renderer", Type = "radioGroup", Radios = new List { new() { Value = "DX9" } } }); - - var saved = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["OldGroup"] = "Whatever" - }; - - Assert.False(SaveValidator.IsValid(config, saved)); - } - - [Fact] - public void IsValid_ReturnsFalse_WhenRadioValueDoesNotExist() - { - var config = MakeConfig( - new OptionGroup - { - Name = "Renderer", - Type = "radioGroup", - Radios = new List { new() { Value = "DX9" } } - }); - - var saved = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["Renderer"] = "DX12" - }; - - Assert.False(SaveValidator.IsValid(config, saved)); - } - - [Fact] - public void IsValid_ReturnsTrue_ForValidCheckboxChoices() - { - var config = MakeConfig( - new OptionGroup - { - Name = "Mods", - Type = "checkboxGroup", - Checkboxes = new List - { - new() { Value = "A" }, - new() { Value = "B" }, - new() { Value = "C" }, - } - }); - - var saved = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["Mods"] = new List { "A", "C" } - }; - - Assert.True(SaveValidator.IsValid(config, saved)); - } - - [Fact] - public void IsValid_ReturnsFalse_WhenCheckboxValueDoesNotExist() - { - var config = MakeConfig( - new OptionGroup - { - Name = "Mods", - Type = "checkboxGroup", - Checkboxes = new List { new() { Value = "A" } } - }); - - var saved = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["Mods"] = new List { "A", "B" } - }; - - Assert.False(SaveValidator.IsValid(config, saved)); - } - - [Fact] - public void IsValid_ReturnsFalse_ForWrongTypes() - { - var config = MakeConfig( - new OptionGroup - { - Name = "Renderer", - Type = "radioGroup", - Radios = new List { new() { Value = "DX9" } } - }, - new OptionGroup - { - Name = "Mods", - Type = "checkboxGroup", - Checkboxes = new List { new() { Value = "A" } } - }); - - Assert.False(SaveValidator.IsValid(config, new Dictionary { ["Renderer"] = new List { "DX9" } })); - Assert.False(SaveValidator.IsValid(config, new Dictionary { ["Mods"] = "A" })); - } -}