From c138900a2d78ab988ab179f6af3c1713508e5592 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Thu, 5 Feb 2026 18:27:46 +0100 Subject: [PATCH 01/45] fix: find the game executable on case-sensitive filesystems The installation is probed for "gta5.exe" and "gta5_enhanced.exe", but the files Rockstar ships are named GTA5.exe and GTA5_Enhanced.exe. On Windows that difference does not exist; anywhere else the probe fails and the folder is rejected as not being a GTA V installation, so the key loader, the folder picker and the ModManager settings all refuse a perfectly good install. The same call sites built their paths by concatenating a literal backslash, which is not a separator off Windows either. They use Path.Combine. Two more in CodeWalker.Core did the same. RpfFile.CreateNew decided whether a path was already absolute by looking for a colon, so an absolute output path was treated as relative and 'pack -o /a/b/out.rpf' wrote a file named 'b\out.rpf' inside /a while reporting success. ExtractScripts joined its output paths the same way, and the Gen9Converter folder walk appended a backslash to normalize a folder. Path is an instance property on RpfFile, so System.IO.Path is spelled out at those call sites. Behaviour is unchanged on Windows. --- CodeWalker.Core/GameFiles/RpfFile.cs | 10 ++++------ CodeWalker.Core/GameFiles/Utils/GTAKeys.cs | 4 ++-- CodeWalker.Core/Utils/Gen9Converter.cs | 9 +++++---- CodeWalker.ModManager/SelectFolderForm.cs | 6 +++--- CodeWalker.ModManager/SettingsFile.cs | 4 ++-- CodeWalker/ExploreForm.cs | 2 +- CodeWalker/Tools/ExtractKeysForm.cs | 2 +- CodeWalker/Utils/GTAFolder.cs | 6 +++--- 8 files changed, 21 insertions(+), 22 deletions(-) diff --git a/CodeWalker.Core/GameFiles/RpfFile.cs b/CodeWalker.Core/GameFiles/RpfFile.cs index bed44c61f..0fa2b6a7b 100644 --- a/CodeWalker.Core/GameFiles/RpfFile.cs +++ b/CodeWalker.Core/GameFiles/RpfFile.cs @@ -421,7 +421,7 @@ private void ExtractScripts(BinaryReader br, string outputfolder, Action updateStatus?.Invoke("Extracting " + resentry.Name + "..."); //found a YSC file. extract it! - string ofpath = outputfolder + "\\" + resentry.Name; + string ofpath = System.IO.Path.Combine(outputfolder, resentry.Name); br.BaseStream.Position = StartPos + ((long)resentry.FileOffset * 512); @@ -442,7 +442,7 @@ private void ExtractScripts(BinaryReader br, string outputfolder, Action decr = GTACrypto.DecryptAES(tbytes); //special case! probable duplicate pilot_school.ysc - ofpath = outputfolder + "\\" + Name + "___" + resentry.Name; + ofpath = System.IO.Path.Combine(outputfolder, Name + "___" + resentry.Name); } else { @@ -464,7 +464,7 @@ private void ExtractScripts(BinaryReader br, string outputfolder, Action bool pathok = true; if (File.Exists(ofpath)) { - ofpath = outputfolder + "\\" + Name + "_" + resentry.Name; + ofpath = System.IO.Path.Combine(outputfolder, Name + "_" + resentry.Name); if (File.Exists(ofpath)) { LastError = "Output file " + ofpath + " already exists!"; @@ -1508,9 +1508,7 @@ public static RpfFile CreateNew(string gtafolder, string relpath, RpfEncryption //create a new, empty RPF file in the filesystem //this will assume that the folder the file is going into already exists! - string fpath = gtafolder; - fpath = fpath.EndsWith("\\") ? fpath : fpath + "\\"; - fpath = relpath.Contains(":") ? relpath : fpath + relpath; + string fpath = System.IO.Path.IsPathRooted(relpath) ? relpath : System.IO.Path.Combine(gtafolder, relpath); if (File.Exists(fpath)) { diff --git a/CodeWalker.Core/GameFiles/Utils/GTAKeys.cs b/CodeWalker.Core/GameFiles/Utils/GTAKeys.cs index b8f8ad862..d73543d74 100644 --- a/CodeWalker.Core/GameFiles/Utils/GTAKeys.cs +++ b/CodeWalker.Core/GameFiles/Utils/GTAKeys.cs @@ -250,8 +250,8 @@ private static void UseMagicData(string path, bool gen9, string key) if (string.IsNullOrEmpty(key)) { - var exefile = gen9 ? "\\gta5_enhanced.exe" : "\\gta5.exe"; - byte[] exedata = File.ReadAllBytes(path + exefile); + var exefile = gen9 ? "GTA5_Enhanced.exe" : "GTA5.exe"; + byte[] exedata = File.ReadAllBytes(Path.Combine(path, exefile)); GenerateV2(exedata, null); } else diff --git a/CodeWalker.Core/Utils/Gen9Converter.cs b/CodeWalker.Core/Utils/Gen9Converter.cs index ddb705783..c80d97496 100644 --- a/CodeWalker.Core/Utils/Gen9Converter.cs +++ b/CodeWalker.Core/Utils/Gen9Converter.cs @@ -40,13 +40,14 @@ public void Convert() Error("Please select an output folder."); return; } - if (inputFolder.EndsWith("\\") == false) + var sep = Path.DirectorySeparatorChar.ToString(); + if (inputFolder.EndsWith(sep) == false) { - inputFolder = inputFolder + "\\"; + inputFolder = inputFolder + sep; } - if (outputFolder.EndsWith("\\") == false) + if (outputFolder.EndsWith(sep) == false) { - outputFolder = outputFolder + "\\"; + outputFolder = outputFolder + sep; } if (inputFolder.Equals(outputFolder, StringComparison.InvariantCultureIgnoreCase)) { diff --git a/CodeWalker.ModManager/SelectFolderForm.cs b/CodeWalker.ModManager/SelectFolderForm.cs index 656b3b7db..8be85d852 100644 --- a/CodeWalker.ModManager/SelectFolderForm.cs +++ b/CodeWalker.ModManager/SelectFolderForm.cs @@ -29,7 +29,7 @@ public SelectFolderForm(SettingsFile settings) public static bool IsGen9Folder(string folder) { - return File.Exists(folder + @"\gta5_enhanced.exe"); + return File.Exists(Path.Combine(folder, "GTA5_Enhanced.exe")); } public static bool ValidateGTAFolder(string folder, bool gen9, out string failReason) @@ -50,7 +50,7 @@ public static bool ValidateGTAFolder(string folder, bool gen9, out string failRe if (gen9) { - if (!File.Exists(folder + @"\gta5_enhanced.exe")) + if (!File.Exists(Path.Combine(folder, "GTA5_Enhanced.exe"))) { failReason = $"GTA5_Enhanced.exe not found in folder \"{folder}\""; return false; @@ -58,7 +58,7 @@ public static bool ValidateGTAFolder(string folder, bool gen9, out string failRe } else { - if (!File.Exists(folder + @"\gta5.exe")) + if (!File.Exists(Path.Combine(folder, "GTA5.exe"))) { failReason = $"GTA5.exe not found in folder \"{folder}\""; return false; diff --git a/CodeWalker.ModManager/SettingsFile.cs b/CodeWalker.ModManager/SettingsFile.cs index 4c66495d4..67556356e 100644 --- a/CodeWalker.ModManager/SettingsFile.cs +++ b/CodeWalker.ModManager/SettingsFile.cs @@ -16,8 +16,8 @@ public class SettingsFile : SimpleKvpFile public string GameName => GameFolderOk ? IsGen9 ? "GTAV (Enhanced)" : "GTAV (Legacy)" : "(None selected)"; public string GameTitle => IsGen9 ? "GTAV Enhanced" : "GTAV Legacy"; - public string GameExeName => IsGen9 ? "gta5_enhanced.exe" : "gta5.exe"; - public string GameExePath => $"{GameFolder}\\{GameExeName}"; + public string GameExeName => IsGen9 ? "GTA5_Enhanced.exe" : "GTA5.exe"; + public string GameExePath => Path.Combine(GameFolder, GameExeName); public string GameModCache => IsGen9 ? "GTAVEnhanced" : "GTAVLegacy"; public bool GameFolderOk { diff --git a/CodeWalker/ExploreForm.cs b/CodeWalker/ExploreForm.cs index 593ec4e97..28db24652 100644 --- a/CodeWalker/ExploreForm.cs +++ b/CodeWalker/ExploreForm.cs @@ -195,7 +195,7 @@ private void Init() } catch { - UpdateStatus("Unable to load gta5.exe!"); + UpdateStatus("Unable to load GTA5.exe!"); return; } diff --git a/CodeWalker/Tools/ExtractKeysForm.cs b/CodeWalker/Tools/ExtractKeysForm.cs index 425d24d5e..187591dee 100644 --- a/CodeWalker/Tools/ExtractKeysForm.cs +++ b/CodeWalker/Tools/ExtractKeysForm.cs @@ -45,7 +45,7 @@ private void FolderBrowseButton_Click(object sender, EventArgs e) { GTAFolder.UpdateGTAFolder(false); FolderTextBox.Text = GTAFolder.CurrentGTAFolder; - ExeTextBox.Text = GTAFolder.CurrentGTAFolder + @"\GTA5.exe"; + ExeTextBox.Text = Path.Combine(GTAFolder.CurrentGTAFolder, "GTA5.exe"); } private void ExeBrowseButton_Click(object sender, EventArgs e) diff --git a/CodeWalker/Utils/GTAFolder.cs b/CodeWalker/Utils/GTAFolder.cs index f600993ec..b3a771648 100644 --- a/CodeWalker/Utils/GTAFolder.cs +++ b/CodeWalker/Utils/GTAFolder.cs @@ -19,7 +19,7 @@ public static class GTAFolder public static bool IsGen9Folder(string folder) { - return File.Exists(folder + @"\gta5_enhanced.exe"); + return File.Exists(Path.Combine(folder, "GTA5_Enhanced.exe")); } public static bool ValidateGTAFolder(string folder, bool gen9, out string failReason) @@ -40,7 +40,7 @@ public static bool ValidateGTAFolder(string folder, bool gen9, out string failRe if (gen9) { - if (!File.Exists(folder + @"\gta5_enhanced.exe")) + if (!File.Exists(Path.Combine(folder, "GTA5_Enhanced.exe"))) { failReason = $"GTA5_Enhanced.exe not found in folder \"{folder}\""; return false; @@ -48,7 +48,7 @@ public static bool ValidateGTAFolder(string folder, bool gen9, out string failRe } else { - if(!File.Exists(folder + @"\gta5.exe")) + if (!File.Exists(Path.Combine(folder, "GTA5.exe"))) { failReason = $"GTA5.exe not found in folder \"{folder}\""; return false; From b7b71113cc3293f7421f265f1689a48451b0a40a Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:26:32 +0200 Subject: [PATCH 02/45] feat(cli): add the command line interface CodeWalker is a WinForms application, so everything it knows about RPF archives is only reachable through a GUI on Windows. CodeWalker.Core already builds on its own; this is a console front end over it. Three commands to start: list, extract and hash. Every one of them takes --rpf and --exe, since reading an archive needs the encryption keys out of the installation, and every one can emit JSON instead of text so the output is scriptable. Targets net48, net8.0 and net10.0. net48 is what the rest of the solution builds against, and the modern targets are what this is actually meant to run on. The three handlers each opened the archive, loaded the keys, walked the entries and reported errors their own way. RpfService and RpfCommandOptions hold that once, so a handler is left with the part that is specific to it. --- CodeWalker.Cli/CodeWalker.Cli.csproj | 47 ++++ CodeWalker.Cli/Compiler.cs | 54 ++++ CodeWalker.Cli/ExtractHandler.cs | 364 ++++++++++++++++++++++++++ CodeWalker.Cli/HashHandler.cs | 152 +++++++++++ CodeWalker.Cli/Helpers/Filter.cs | 89 +++++++ CodeWalker.Cli/Helpers/ProgressBar.cs | 143 ++++++++++ CodeWalker.Cli/Helpers/SizeFormat.cs | 57 ++++ CodeWalker.Cli/Json/ExtractResult.cs | 37 +++ CodeWalker.Cli/Json/FileEntry.cs | 24 ++ CodeWalker.Cli/Json/HashResult.cs | 34 +++ CodeWalker.Cli/Json/ListResult.cs | 31 +++ CodeWalker.Cli/ListHandler.cs | 226 ++++++++++++++++ CodeWalker.Cli/Program.cs | 11 + CodeWalker.Cli/RpfOptions.cs | 107 ++++++++ CodeWalker.Cli/RpfService.cs | 143 ++++++++++ CodeWalker.sln | 14 + 16 files changed, 1533 insertions(+) create mode 100644 CodeWalker.Cli/CodeWalker.Cli.csproj create mode 100644 CodeWalker.Cli/Compiler.cs create mode 100644 CodeWalker.Cli/ExtractHandler.cs create mode 100644 CodeWalker.Cli/HashHandler.cs create mode 100644 CodeWalker.Cli/Helpers/Filter.cs create mode 100644 CodeWalker.Cli/Helpers/ProgressBar.cs create mode 100644 CodeWalker.Cli/Helpers/SizeFormat.cs create mode 100644 CodeWalker.Cli/Json/ExtractResult.cs create mode 100644 CodeWalker.Cli/Json/FileEntry.cs create mode 100644 CodeWalker.Cli/Json/HashResult.cs create mode 100644 CodeWalker.Cli/Json/ListResult.cs create mode 100644 CodeWalker.Cli/ListHandler.cs create mode 100644 CodeWalker.Cli/Program.cs create mode 100644 CodeWalker.Cli/RpfOptions.cs create mode 100644 CodeWalker.Cli/RpfService.cs diff --git a/CodeWalker.Cli/CodeWalker.Cli.csproj b/CodeWalker.Cli/CodeWalker.Cli.csproj new file mode 100644 index 000000000..0f27d7161 --- /dev/null +++ b/CodeWalker.Cli/CodeWalker.Cli.csproj @@ -0,0 +1,47 @@ + + + Exe + net48;net8.0;net10.0 + latest + enable + disable + dexyfex + dexyfex software + dexyfex + Command-line tool for extracting GTA V RPF archives + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + true + latest + + + + + + diff --git a/CodeWalker.Cli/Compiler.cs b/CodeWalker.Cli/Compiler.cs new file mode 100644 index 000000000..58c301c74 --- /dev/null +++ b/CodeWalker.Cli/Compiler.cs @@ -0,0 +1,54 @@ +// Source - https://stackoverflow.com/a/74447498 +// Posted by Matthew Watson +// Retrieved 2026-02-06, License - CC BY-SA 4.0 + +#if !NET5_0_OR_GREATER +using System.ComponentModel; +#endif + +namespace System.Runtime.CompilerServices +{ +#if !NET5_0_OR_GREATER + + [EditorBrowsable(EditorBrowsableState.Never)] + internal static class IsExternalInit { } + +#endif // !NET5_0_OR_GREATER + +#if !NET7_0_OR_GREATER + + [AttributeUsage( + AttributeTargets.Class + | AttributeTargets.Struct + | AttributeTargets.Field + | AttributeTargets.Property, + AllowMultiple = false, + Inherited = false + )] + internal sealed class RequiredMemberAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)] + internal sealed class CompilerFeatureRequiredAttribute : Attribute + { + public CompilerFeatureRequiredAttribute(string featureName) + { + FeatureName = featureName; + } + + public string FeatureName { get; } + public bool IsOptional { get; init; } + + public const string RefStructs = nameof(RefStructs); + public const string RequiredMembers = nameof(RequiredMembers); + } + +#endif // !NET7_0_OR_GREATER +} + +namespace System.Diagnostics.CodeAnalysis +{ +#if !NET7_0_OR_GREATER + [AttributeUsage(AttributeTargets.Constructor, AllowMultiple = false, Inherited = false)] + internal sealed class SetsRequiredMembersAttribute : Attribute { } +#endif +} diff --git a/CodeWalker.Cli/ExtractHandler.cs b/CodeWalker.Cli/ExtractHandler.cs new file mode 100644 index 000000000..bdc6e3183 --- /dev/null +++ b/CodeWalker.Cli/ExtractHandler.cs @@ -0,0 +1,364 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; + +namespace CodeWalker.Cli; + +public record ExtractOptions +{ + public required RpfOptions Rpf { get; init; } + public required string? OutputPath { get; init; } + public required bool DryRun { get; init; } + public required bool Progress { get; init; } +} + +public static class ExtractHandler +{ + public static Command CreateCommand() + { + RpfCommandOptions rpfOpts = new(); + // csharpier-ignore-start + Option outputOption = new("--output", "-o") + { + Description = "Output directory", + DefaultValueFactory = _ => new DirectoryInfo(Directory.GetCurrentDirectory()), + }; + + Option dryRunOption = new("--dry-run", "-n") + { + Description = "Show what would be extracted without actually extracting", + }; + + Option progressOption = new("--progress", "-P") + { + Description = "Show progress bar during extraction", + }; + // csharpier-ignore-end + + Command command = new("extract", "Extract files from an RPF archive") + { + outputOption, + dryRunOption, + progressOption, + }; + rpfOpts.AddTo(command); + command.Aliases.Add("x"); + + command.SetAction(parseResult => + { + ExtractOptions options = new() + { + Rpf = rpfOpts.Parse(parseResult), + OutputPath = parseResult.GetValue(outputOption)?.FullName, + DryRun = parseResult.GetValue(dryRunOption), + Progress = parseResult.GetValue(progressOption), + }; + return Execute(options); + }); + + return command; + } + + public static int Execute(ExtractOptions options) + { + List files = []; + List errorMessages = []; + + Json.ExtractResult result = new() + { + Success = false, + RpfFile = null!, + OutputDir = null!, + TotalFiles = 0, + Extracted = 0, + Skipped = 0, + Errors = 0, + DryRun = options.DryRun, + Files = files, + ErrorMessages = errorMessages, + }; + + string? validationError = RpfService.ValidateInputs( + options.Rpf.RpfPath, + options.Rpf.ExePath, + options.Rpf.Gen9 + ); + if (validationError != null) + { + return ReportError(validationError, options, result); + } + + try + { + if (!options.Rpf.Json) + { + Console.Error.WriteLine("Loading encryption keys..."); + } + RpfService.LoadKeys(options.Rpf.ExePath, options.Rpf.Gen9); + + if (!options.Rpf.Json) + { + Console.Error.WriteLine($"Opening RPF: {options.Rpf.RpfPath}"); + } + + RpfFile rpf = RpfService.OpenRpf( + options.Rpf.RpfPath, + onStatus: status => + { + if (options.Rpf.Verbose && !options.Rpf.Json) + Console.Error.WriteLine(status); + }, + onError: error => + { + if (!options.Rpf.Json) + Console.Error.WriteLine($"Error: {error}"); + errorMessages.Add(error); + } + ); + + if (!options.Rpf.Json) + { + Console.Error.WriteLine( + $"Found {rpf.GrandTotalFileCount} files in {rpf.GrandTotalRpfCount} archive(s)" + ); + } + + result = result with + { + RpfFile = options.Rpf.RpfPath, + OutputDir = options.OutputPath ?? Directory.GetCurrentDirectory(), + TotalFiles = rpf.GrandTotalFileCount, + }; + + if (!options.Rpf.Json && options.DryRun) + { + Console.Error.WriteLine("Dry run mode - no files will be extracted"); + } + + string outputDir = options.OutputPath ?? Directory.GetCurrentDirectory(); + + if (!options.DryRun && !Directory.Exists(outputDir)) + { + Directory.CreateDirectory(outputDir); + } + + // Collect files first for progress bar + List<(RpfFile rpf, RpfFileEntry entry)> filesToExtract = RpfService.CollectFiles( + rpf, + options.Rpf.Filters, + options.Rpf.Recursive + ); + + // Count non-RPF files that were excluded by filters + int totalNonRpfFiles = RpfService.CountNonRpfFiles(rpf, options.Rpf.Recursive); + int skipped = totalNonRpfFiles - filesToExtract.Count; + + // Process files in parallel, storing results by index to preserve order + (bool success, Json.FileEntry? jsonEntry, string? errorMessage)[] results = new ( + bool, + Json.FileEntry?, + string? + )[filesToExtract.Count]; + + object consoleLock = new(); + + using ( + ProgressBar progress = new( + filesToExtract.Count, + options.Progress && !options.Rpf.Json + ) + ) + { + Parallel.For( + 0, + filesToExtract.Count, + new ParallelOptions + { + MaxDegreeOfParallelism = Math.Max(1, options.Rpf.Threads), + }, + i => + { + (RpfFile sourceRpf, RpfFileEntry fileEntry) = filesToExtract[i]; + try + { + string relativePath = fileEntry.Path; + string outputPath = Path.Combine( + outputDir, + relativePath.Replace("\\", Path.DirectorySeparatorChar.ToString()) + ); + string? fileDir = Path.GetDirectoryName(outputPath); + + long size = fileEntry.GetFileSize(); + string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); + + Json.FileEntry jsonEntry = new() + { + Path = fileEntry.Path, + Name = fileEntry.Name, + Size = size, + SizeFormatted = options.Rpf.SizeFormat.ToFormattedString(size), + Type = RpfService.GetFileType(fileEntry), + Extension = ext, + }; + + if (options.DryRun) + { + if (options.Rpf.Verbose && !options.Rpf.Json) + { + lock (consoleLock) + { + Console.WriteLine($"Would extract: {fileEntry.Path}"); + } + } + results[i] = (true, jsonEntry, null); + } + else + { + if (!string.IsNullOrEmpty(fileDir) && !Directory.Exists(fileDir)) + { + Directory.CreateDirectory(fileDir); + } + + if (options.Rpf.Verbose && !options.Rpf.Json && !options.Progress) + { + lock (consoleLock) + { + Console.Error.WriteLine($"Extracting: {fileEntry.Path}"); + } + } + + byte[]? data = sourceRpf.ExtractFile(fileEntry); + if (data != null) + { + File.WriteAllBytes(outputPath, data); + results[i] = (true, jsonEntry, null); + } + else + { + if (options.Rpf.Verbose && !options.Rpf.Json) + { + lock (consoleLock) + { + Console.Error.WriteLine( + $"Warning: Failed to extract {fileEntry.Path}" + ); + } + } + results[i] = ( + false, + null, + $"Failed to extract: {fileEntry.Path}" + ); + } + } + + progress.Increment(fileEntry.Path); + } + catch (Exception ex) + { + if (!options.Rpf.Json) + { + lock (consoleLock) + { + Console.Error.WriteLine( + $"Error extracting {fileEntry.Path}: {ex.Message}" + ); + } + } + results[i] = ( + false, + null, + $"Error extracting {fileEntry.Path}: {ex.Message}" + ); + progress.Increment(); + } + } + ); + } + + // Aggregate results in order + int extracted = 0; + int errors = 0; + foreach (var (success, jsonEntry, errorMessage) in results) + { + if (success) + { + extracted++; + if (jsonEntry != null) + files.Add(jsonEntry); + } + else if (errorMessage != null) + { + errors++; + errorMessages.Add(errorMessage); + } + } + + result = result with + { + Extracted = extracted, + Skipped = skipped, + Errors = errors, + Success = errors == 0, + }; + + if (options.Rpf.Json) + { + Console.WriteLine( + JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + ); + } + else + { + Console.Error.WriteLine(); + string action = options.DryRun ? "would be extracted" : "extracted"; + Console.Error.WriteLine( + $"Extraction complete: {extracted} files {action}, {skipped} skipped, {errors} errors" + ); + } + + return errors > 0 ? 1 : 0; + } + catch (Exception ex) + { + return ReportError( + ex.Message, + options, + result, + options.Rpf.Verbose ? ex.StackTrace : null + ); + } + } + + private static int ReportError( + string message, + ExtractOptions options, + Json.ExtractResult result, + string? stackTrace = null + ) + { + if (options.Rpf.Json) + { + result = result with + { + Success = false, + ErrorMessages = [.. result.ErrorMessages, message], + }; + Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); + } + else + { + Console.Error.WriteLine($"Error: {message}"); + if (stackTrace != null) + { + Console.Error.WriteLine(stackTrace); + } + } + return 1; + } +} diff --git a/CodeWalker.Cli/HashHandler.cs b/CodeWalker.Cli/HashHandler.cs new file mode 100644 index 000000000..18c395b33 --- /dev/null +++ b/CodeWalker.Cli/HashHandler.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.Text.Json; +using CodeWalker.GameFiles; + +namespace CodeWalker.Cli; + +public record HashOptions +{ + public required string[] Inputs { get; init; } + public required string Encoding { get; init; } + public required bool Json { get; init; } + + public const string DefaultEncoding = "utf8"; + public const JenkHashInputEncoding DefaultJenkHashEncoding = JenkHashInputEncoding.UTF8; +} + +public static class HashHandler +{ + public static Command CreateCommand() + { + // csharpier-ignore-start + Option inputOption = new("--input", "-i") + { + Description = "Text string(s) to hash", + Required = true, + AllowMultipleArgumentsPerToken = true, + }; + + Option encodingOption = new("--encoding", "-e") + { + Description = "Encoding: utf-8 (default), ascii", + DefaultValueFactory = _ => HashOptions.DefaultEncoding, + }; + + Option jsonOption = new("--json") { + Description = "Output results in JSON format", + }; + // csharpier-ignore-end + + Command command = new("hash", "Generate Jenkins hashes for GTA V game identifiers") + { + inputOption, + encodingOption, + jsonOption, + }; + command.Aliases.Add("h"); + + command.SetAction(parseResult => + { + HashOptions options = new() + { + Inputs = parseResult.GetRequiredValue(inputOption), + Encoding = parseResult.GetValue(encodingOption) ?? HashOptions.DefaultEncoding, + Json = parseResult.GetValue(jsonOption), + }; + return Execute(options); + }); + + return command; + } + + public static int Execute(HashOptions options) + { + List hashes = []; + + Json.HashResult result = new() + { + Success = false, + Hashes = hashes, + ErrorMessage = null, + }; + + // Validate encoding + JenkHashInputEncoding encoding; + switch (options.Encoding.ToLowerInvariant()) + { + case HashOptions.DefaultEncoding: + encoding = HashOptions.DefaultJenkHashEncoding; + break; + case "utf-8": + encoding = JenkHashInputEncoding.UTF8; + break; + case "ascii": + encoding = JenkHashInputEncoding.ASCII; + break; + default: + return ReportError( + $"Unknown encoding: {options.Encoding}. Use 'utf-8' or 'ascii'.", + options, + result + ); + } + + try + { + foreach (string input in options.Inputs) + { + JenkHash jenkHash = new(input, encoding); + + Json.HashEntry entry = new() + { + Input = input, + Hash = jenkHash.HashUint, + HashSigned = jenkHash.HashInt, + HashHex = jenkHash.HashHex, + Encoding = jenkHash.Encoding.ToString(), + }; + + hashes.Add(entry); + + if (!options.Json) + { + Console.WriteLine($"Input: {input}"); + Console.WriteLine($" Hash (uint): {jenkHash.HashUint}"); + Console.WriteLine($" Hash (int): {jenkHash.HashInt}"); + Console.WriteLine($" Hash (hex): {jenkHash.HashHex}"); + } + } + + result = result with { Success = true }; + + if (options.Json) + { + Console.WriteLine( + JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + ); + } + + return 0; + } + catch (Exception ex) + { + return ReportError(ex.Message, options, result); + } + } + + private static int ReportError(string message, HashOptions options, Json.HashResult result) + { + if (options.Json) + { + result = result with { Success = false, ErrorMessage = message }; + Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); + } + else + { + Console.Error.WriteLine($"Error: {message}"); + } + return 1; + } +} diff --git a/CodeWalker.Cli/Helpers/Filter.cs b/CodeWalker.Cli/Helpers/Filter.cs new file mode 100644 index 000000000..febb71027 --- /dev/null +++ b/CodeWalker.Cli/Helpers/Filter.cs @@ -0,0 +1,89 @@ +using System.Collections.Concurrent; +using System.Text.RegularExpressions; + +namespace CodeWalker.Cli.Helpers; + +/// +/// Provides methods for filtering file paths based on glob patterns. +/// +public static class Filter +{ + private static readonly ConcurrentDictionary RegexCache = new(); + + /// + /// Determines if the given path matches any of the provided glob patterns. + /// + /// Path to check. + /// Glob patterns to match against. + /// True if the path matches any pattern; otherwise, false. + public static bool Matches(string path, string[]? filters) + { + if (filters == null || filters.Length == 0) + return true; + + string nameLower = path.ToLowerInvariant(); + + foreach (string filter in filters) + { + if (string.IsNullOrWhiteSpace(filter)) + continue; + + string p = filter.Trim().ToLowerInvariant(); + + if (MatchesGlob(nameLower, p)) + return true; + } + + return false; + } + + private static bool MatchesGlob(string input, string pattern) + { + // Normalize path separators + input = input.Replace('\\', '/'); + pattern = pattern.Replace('\\', '/'); + + bool hasPathSep = pattern.Contains("/"); + + // For patterns without path separators, match against filename only + if (!hasPathSep) + { + int lastSlash = input.LastIndexOf("/"); + if (lastSlash >= 0) + input = input[(lastSlash + 1)..]; + } + + // Handle extension-only patterns (e.g., ".ydr" or "ydr" without wildcards) + if (!pattern.Contains("*") && !pattern.Contains("?")) + { + if (pattern.StartsWith(".")) + return input.EndsWith(pattern); + else + return input.EndsWith("." + pattern); + } + + Regex regex = RegexCache.GetOrAdd( + pattern, + static p => + { + // Convert glob pattern to regex + // Escape all regex special chars except * and ? + string regexPattern = Regex + .Escape(p) + .Replace("\\*", ".*") // * matches any sequence of characters + .Replace("\\?", "."); // ? matches any single character + + // Patterns with path separators match at any path boundary; + // filename-only patterns are anchored to the full filename. + if (p.Contains("/")) + regexPattern = "(?:^|/)" + regexPattern + "$"; + else + regexPattern = "^" + regexPattern + "$"; + + return new Regex(regexPattern, RegexOptions.Compiled); + } + ); + + return regex.IsMatch(input); + } +} diff --git a/CodeWalker.Cli/Helpers/ProgressBar.cs b/CodeWalker.Cli/Helpers/ProgressBar.cs new file mode 100644 index 000000000..3c5b2909a --- /dev/null +++ b/CodeWalker.Cli/Helpers/ProgressBar.cs @@ -0,0 +1,143 @@ +using System; +using System.IO; +using System.Security; + +namespace CodeWalker.Cli.Helpers; + +/// +/// Displays a console progress bar on stderr to keep stdout clean for data/JSON output. +/// +public class ProgressBar : IDisposable +{ + private readonly int _total; + private int _current; + private readonly bool _enabled; + private readonly int _barWidth = 40; + private DateTime _lastUpdate = DateTime.MinValue; + private readonly object _lock = new(); + private static TextWriter Err => Console.Error; + + /// + /// Initializes a new instance of the ProgressBar class. + /// + /// Total number of items to process. + /// Whether to enable the progress bar display. + public ProgressBar(int total, bool enabled) + { + _total = total; + _enabled = enabled && total > 0 && !Console.IsErrorRedirected; + if (_enabled) + { + try + { + Console.CursorVisible = false; + } + catch { } + Render(); + } + } + + /// + /// Updates the progress bar to the specified current value. + /// + /// Current number of items processed. + /// Optional current file being processed. + public void Update(int current, string? currentFile = null) + { + lock (_lock) + { + _current = current; + if (!_enabled) + return; + + // Throttle updates to avoid flickering + if ((DateTime.Now - _lastUpdate).TotalMilliseconds < 50 && current < _total) + return; + + _lastUpdate = DateTime.Now; + Render(currentFile); + } + } + + /// + /// Increments the progress bar by one. + /// Thread-safe: the increment and render happen atomically under a lock. + /// + /// Optional current file being processed. + public void Increment(string? currentFile = null) + { + lock (_lock) + { + _current++; + if (!_enabled) + return; + + if ((DateTime.Now - _lastUpdate).TotalMilliseconds < 50 && _current < _total) + return; + + _lastUpdate = DateTime.Now; + Render(currentFile); + } + } + + private void Render(string? currentFile = null) + { + if (!_enabled) + return; + + try + { + double percent = _total > 0 ? (double)_current / _total : 0; + int filled = (int)(percent * _barWidth); + + Console.SetCursorPosition(0, Console.CursorTop); + Err.Write("["); + Err.Write(new string('=', filled)); + if (filled < _barWidth) + { + Err.Write(">"); + Err.Write(new string(' ', _barWidth - filled - 1)); + } + Err.Write($"] {percent, 6:P0} ({_current}/{_total})"); + + if (!string.IsNullOrEmpty(currentFile)) + { + int maxLen = Math.Max(10, Console.WindowWidth - _barWidth - 30); + string displayFile = + currentFile!.Length > maxLen + ? $"...{currentFile[(currentFile.Length - maxLen + 3)..]}" + : currentFile; + Err.Write($" {displayFile}"); + } + + // Clear rest of line + int remaining = Console.WindowWidth - Console.CursorLeft - 1; + if (remaining > 0) + { + Err.Write(new string(' ', remaining)); + } + } + catch (Exception ex) + when (ex is IOException or InvalidOperationException or SecurityException) + { + // Ignore console errors (e.g. redirected output, no terminal) + } + } + + /// + /// Disposes the progress bar, ensuring the console state is restored. + /// + public void Dispose() + { + if (_enabled) + { + try + { + Err.WriteLine(); + Console.CursorVisible = true; + } + catch (Exception ex) + when (ex is IOException or InvalidOperationException or SecurityException) { } + } + } +} diff --git a/CodeWalker.Cli/Helpers/SizeFormat.cs b/CodeWalker.Cli/Helpers/SizeFormat.cs new file mode 100644 index 000000000..8b20fcd8a --- /dev/null +++ b/CodeWalker.Cli/Helpers/SizeFormat.cs @@ -0,0 +1,57 @@ +using System; + +namespace CodeWalker.Cli.Helpers; + +/// +/// Defines size formatting options for human-readable file sizes. +/// +public enum SizeFormat +{ + /// IEC format: 1024-based (KiB, MiB, GiB) + IEC, + + /// SI format: 1000-based (KB, MB, GB) + SI, +} + +/// +/// Extension methods for SizeFormat to format byte sizes into human-readable strings. +/// +public static class SizeFormatExtensions +{ + private static readonly string[] SiSuffixes = ["B", "KB", "MB", "GB", "TB", "PB"]; + private static readonly string[] IecSuffixes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + + private static double GetDivisor(this SizeFormat format) => + format switch + { + SizeFormat.SI => 1000.0, + SizeFormat.IEC => 1024.0, + _ => throw new ArgumentOutOfRangeException(nameof(format)), + }; + + private static string[] GetSuffixes(this SizeFormat format) => + format switch + { + SizeFormat.SI => SiSuffixes, + SizeFormat.IEC => IecSuffixes, + _ => throw new ArgumentOutOfRangeException(nameof(format)), + }; + + /// + /// Formats the given byte size into a human-readable string based on the size format. + /// + public static string ToFormattedString(this SizeFormat format, long bytes) + { + double divisor = format.GetDivisor(); + string[] suffixes = format.GetSuffixes(); + int i = 0; + double size = bytes; + while (size >= divisor && i < suffixes.Length - 1) + { + size /= divisor; + i++; + } + return $"{size:0.##} {suffixes[i]}"; + } +} diff --git a/CodeWalker.Cli/Json/ExtractResult.cs b/CodeWalker.Cli/Json/ExtractResult.cs new file mode 100644 index 000000000..dda5ced67 --- /dev/null +++ b/CodeWalker.Cli/Json/ExtractResult.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace CodeWalker.Cli.Json; + +public record ExtractResult +{ + [JsonPropertyName("success")] + public required bool Success { get; init; } + + [JsonPropertyName("rpfFile")] + public required string RpfFile { get; init; } + + [JsonPropertyName("outputDir")] + public required string OutputDir { get; init; } + + [JsonPropertyName("totalFiles")] + public required uint TotalFiles { get; init; } + + [JsonPropertyName("extracted")] + public required int Extracted { get; init; } + + [JsonPropertyName("skipped")] + public required int Skipped { get; init; } + + [JsonPropertyName("errors")] + public required int Errors { get; init; } + + [JsonPropertyName("dryRun")] + public required bool DryRun { get; init; } + + [JsonPropertyName("files")] + public required IReadOnlyList Files { get; init; } + + [JsonPropertyName("errorMessages")] + public required IReadOnlyList ErrorMessages { get; init; } +} diff --git a/CodeWalker.Cli/Json/FileEntry.cs b/CodeWalker.Cli/Json/FileEntry.cs new file mode 100644 index 000000000..c21b61cbd --- /dev/null +++ b/CodeWalker.Cli/Json/FileEntry.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace CodeWalker.Cli.Json; + +public record FileEntry +{ + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("size")] + public required long Size { get; init; } + + [JsonPropertyName("sizeFormatted")] + public required string SizeFormatted { get; init; } + + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("extension")] + public required string Extension { get; init; } +} diff --git a/CodeWalker.Cli/Json/HashResult.cs b/CodeWalker.Cli/Json/HashResult.cs new file mode 100644 index 000000000..745edc9b3 --- /dev/null +++ b/CodeWalker.Cli/Json/HashResult.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace CodeWalker.Cli.Json; + +public record HashResult +{ + [JsonPropertyName("success")] + public required bool Success { get; init; } + + [JsonPropertyName("hashes")] + public required IReadOnlyList Hashes { get; init; } + + [JsonPropertyName("errorMessage")] + public required string? ErrorMessage { get; init; } +} + +public record HashEntry +{ + [JsonPropertyName("input")] + public required string Input { get; init; } + + [JsonPropertyName("hash")] + public required uint Hash { get; init; } + + [JsonPropertyName("hashSigned")] + public required int HashSigned { get; init; } + + [JsonPropertyName("hashHex")] + public required string HashHex { get; init; } + + [JsonPropertyName("encoding")] + public required string Encoding { get; init; } +} diff --git a/CodeWalker.Cli/Json/ListResult.cs b/CodeWalker.Cli/Json/ListResult.cs new file mode 100644 index 000000000..d63ee742e --- /dev/null +++ b/CodeWalker.Cli/Json/ListResult.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace CodeWalker.Cli.Json; + +public record ListResult +{ + [JsonPropertyName("success")] + public required bool Success { get; init; } + + [JsonPropertyName("rpfFile")] + public required string RpfFile { get; init; } + + [JsonPropertyName("totalFiles")] + public required int TotalFiles { get; init; } + + [JsonPropertyName("totalSize")] + public required long TotalSize { get; init; } + + [JsonPropertyName("totalSizeFormatted")] + public required string TotalSizeFormatted { get; init; } + + [JsonPropertyName("nestedRpfCount")] + public required uint NestedRpfCount { get; init; } + + [JsonPropertyName("files")] + public required IReadOnlyList Files { get; init; } + + [JsonPropertyName("errorMessages")] + public required IReadOnlyList ErrorMessages { get; init; } +} diff --git a/CodeWalker.Cli/ListHandler.cs b/CodeWalker.Cli/ListHandler.cs new file mode 100644 index 000000000..ad6f81bd1 --- /dev/null +++ b/CodeWalker.Cli/ListHandler.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; + +namespace CodeWalker.Cli; + +public static class ListHandler +{ + public static Command CreateCommand() + { + RpfCommandOptions rpfOpts = new(); + + Command command = new("list", "List contents of an RPF archive"); + rpfOpts.AddTo(command); + command.Aliases.Add("l"); + + command.SetAction(parseResult => + { + return Execute(rpfOpts.Parse(parseResult)); + }); + + return command; + } + + public static int Execute(RpfOptions options) + { + List files = []; + List errorMessages = []; + + Json.ListResult result = new() + { + Success = false, + RpfFile = null!, + TotalFiles = 0, + TotalSize = 0, + TotalSizeFormatted = null!, + NestedRpfCount = 0, + Files = files, + ErrorMessages = errorMessages, + }; + + string? validationError = RpfService.ValidateInputs( + options.RpfPath, + options.ExePath, + options.Gen9 + ); + if (validationError != null) + { + return ReportError(validationError, options, result); + } + + try + { + if (!options.Json) + { + Console.Error.WriteLine("Loading encryption keys..."); + } + RpfService.LoadKeys(options.ExePath, options.Gen9); + + if (!options.Json) + { + Console.Error.WriteLine($"Opening RPF: {options.RpfPath}"); + } + + RpfFile rpf = RpfService.OpenRpf( + options.RpfPath, + onStatus: status => + { + if (options.Verbose && !options.Json) + Console.Error.WriteLine(status); + }, + onError: error => + { + if (!options.Json) + Console.Error.WriteLine($"Error: {error}"); + errorMessages.Add(error); + } + ); + + if (!options.Json) + { + Console.Error.WriteLine( + $"Found {rpf.GrandTotalFileCount} files in {rpf.GrandTotalRpfCount} archive(s)" + ); + } + + result = result with + { + RpfFile = options.RpfPath, + NestedRpfCount = rpf.GrandTotalRpfCount, + }; + + if (!options.Json) + { + Console.Error.WriteLine(); + } + + // Collect all matching entries + List<(RpfFile rpf, RpfFileEntry entry)> entries = RpfService.CollectFiles( + rpf, + options.Filters, + options.Recursive + ); + + // Process entries in parallel, storing results by index to preserve order + (Json.FileEntry? jsonEntry, string? line, long size)[] results = new ( + Json.FileEntry?, + string?, + long + )[entries.Count]; + + Parallel.For( + 0, + entries.Count, + new ParallelOptions { MaxDegreeOfParallelism = Math.Max(1, options.Threads) }, + i => + { + RpfFileEntry fileEntry = entries[i].entry; + long size = fileEntry.GetFileSize(); + string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); + + Json.FileEntry? jsonEntry = null; + string? line = null; + + if (options.Json) + { + jsonEntry = new Json.FileEntry + { + Path = fileEntry.Path, + Name = fileEntry.Name, + Size = size, + SizeFormatted = options.SizeFormat.ToFormattedString(size), + Type = RpfService.GetFileType(fileEntry), + Extension = ext, + }; + } + else if (options.Verbose) + { + string sizeStr = options.SizeFormat.ToFormattedString(size).PadLeft(12); + line = $"{sizeStr} {fileEntry.Path}"; + } + else + { + line = fileEntry.Path; + } + + results[i] = (jsonEntry, line, size); + } + ); + + // Output results sequentially to preserve order + long totalSize = 0; + int fileCount = 0; + foreach (var (jsonEntry, line, size) in results) + { + totalSize += size; + fileCount++; + + if (jsonEntry != null) + files.Add(jsonEntry); + else if (line != null) + Console.WriteLine(line); + } + + result = result with + { + Success = errorMessages.Count == 0, + TotalFiles = fileCount, + TotalSize = totalSize, + TotalSizeFormatted = options.SizeFormat.ToFormattedString(totalSize), + }; + + if (options.Json) + { + Console.WriteLine( + JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + ); + } + else + { + Console.Error.WriteLine(); + Console.Error.WriteLine( + $"Total: {fileCount} files, {options.SizeFormat.ToFormattedString(totalSize)}" + ); + } + + return 0; + } + catch (Exception ex) + { + return ReportError(ex.Message, options, result, options.Verbose ? ex.StackTrace : null); + } + } + + private static int ReportError( + string message, + RpfOptions options, + Json.ListResult result, + string? stackTrace = null + ) + { + if (options.Json) + { + result = result with + { + Success = false, + ErrorMessages = [.. result.ErrorMessages, message], + }; + Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); + } + else + { + Console.Error.WriteLine($"Error: {message}"); + if (stackTrace != null) + { + Console.Error.WriteLine(stackTrace); + } + } + return 1; + } +} diff --git a/CodeWalker.Cli/Program.cs b/CodeWalker.Cli/Program.cs new file mode 100644 index 000000000..5f44ac0a7 --- /dev/null +++ b/CodeWalker.Cli/Program.cs @@ -0,0 +1,11 @@ +using System.CommandLine; +using CodeWalker.Cli; + +RootCommand rootCommand = new(description: "CodeWalker CLI - RPF Archive Tool") +{ + ExtractHandler.CreateCommand(), + ListHandler.CreateCommand(), + HashHandler.CreateCommand(), +}; + +return rootCommand.Parse(args).Invoke(); diff --git a/CodeWalker.Cli/RpfOptions.cs b/CodeWalker.Cli/RpfOptions.cs new file mode 100644 index 000000000..9bd9e534e --- /dev/null +++ b/CodeWalker.Cli/RpfOptions.cs @@ -0,0 +1,107 @@ +using System; +using System.CommandLine; +using System.IO; +using CodeWalker.Cli.Helpers; + +namespace CodeWalker.Cli; + +public record RpfOptions +{ + public required string RpfPath { get; init; } + public required string ExePath { get; init; } + public required bool Gen9 { get; init; } + public required string[] Filters { get; init; } + public required bool Verbose { get; init; } + public required bool Json { get; init; } + public required bool Recursive { get; init; } + public required int Threads { get; init; } + public required SizeFormat SizeFormat { get; init; } +} + +/// +/// Shared System.CommandLine option definitions for RPF-based commands. +/// Create an instance, call to register options on a command, +/// then call inside the action to build an . +/// +public sealed class RpfCommandOptions +{ + // csharpier-ignore-start + public Option Rpf { get; } = new("--rpf", "-r") + { + Description = "Path to the RPF file", + Required = true, + }; + + public Option Exe { get; } = new("--exe", "-e") + { + Description = "Path to the GTA V installation directory (containing GTA5.exe)", + Required = true, + }; + + public Option Gen9 { get; } = new("--gen9", "-g") + { + Description = "Use GTA V Enhanced (Gen9) mode", + }; + + public Option Filter { get; } = new("--filter", "-f") + { + Description = "Filter files by glob patterns (e.g. *.ydd); can be specified multiple times", + AllowMultipleArgumentsPerToken = true, + }; + + public Option Verbose { get; } = new("--verbose", "-v") + { + Description = "Show verbose output", + }; + + public Option Json { get; } = new("--json") + { + Description = "Output results in JSON format for scripting", + }; + + public Option Recursive { get; } = new("--recursive", "-R") + { + Description = "Process nested RPF archives", + }; + + public Option Si { get; } = new("--si") + { + Description = "Use SI units (1000-based: KB, MB) instead of IEC (1024-based: KiB, MiB)", + }; + + public Option Threads { get; } = new("--threads", "-t") + { + Description = "Number of threads for parallel processing", + DefaultValueFactory = _ => Environment.ProcessorCount, + }; + // csharpier-ignore-end + + public void AddTo(Command command) + { + command.Add(Rpf); + command.Add(Exe); + command.Add(Gen9); + command.Add(Filter); + command.Add(Verbose); + command.Add(Json); + command.Add(Recursive); + command.Add(Si); + command.Add(Threads); + } + + public RpfOptions Parse(ParseResult parseResult) + { + return new RpfOptions + { + RpfPath = parseResult.GetRequiredValue(Rpf).FullName, + ExePath = parseResult.GetRequiredValue(Exe).FullName, + Gen9 = parseResult.GetValue(Gen9), + Filters = parseResult.GetValue(Filter) ?? [], + Verbose = parseResult.GetValue(Verbose), + Json = parseResult.GetValue(Json), + Recursive = parseResult.GetValue(Recursive), + Threads = parseResult.GetValue(Threads), + SizeFormat = parseResult.GetValue(Si) ? SizeFormat.SI : SizeFormat.IEC, + }; + } +} diff --git a/CodeWalker.Cli/RpfService.cs b/CodeWalker.Cli/RpfService.cs new file mode 100644 index 000000000..1ece33e15 --- /dev/null +++ b/CodeWalker.Cli/RpfService.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; + +namespace CodeWalker.Cli; + +public static class RpfService +{ + public static readonly JsonSerializerOptions JsonSerializerOptions = new() + { + WriteIndented = true, + }; + + /// + /// Validates that the RPF file and GTA V executable exist. + /// Returns null on success, or an error message on failure. + /// + public static string? ValidateInputs(string rpfPath, string exePath, bool gen9) + { + if (!File.Exists(rpfPath)) + return $"RPF file not found: {rpfPath}"; + + string exeFile = gen9 ? "GTA5_Enhanced.exe" : "GTA5.exe"; + if (!File.Exists(Path.Combine(exePath, exeFile))) + return $"{exeFile} not found in: {exePath}"; + + return null; + } + + /// + /// Loads GTA V encryption keys from the installation directory. + /// + public static void LoadKeys(string exePath, bool gen9) + { + GTA5Keys.LoadFromPath(exePath, gen9); + } + + /// + /// Opens an RPF file and scans its structure. + /// + public static RpfFile OpenRpf( + string rpfPath, + Action? onStatus = null, + Action? onError = null + ) + { + string rpfName = Path.GetFileName(rpfPath); + RpfFile rpf = new(rpfPath, rpfName); + + rpf.ScanStructure(status => onStatus?.Invoke(status), error => onError?.Invoke(error)); + + return rpf; + } + + /// + /// Recursively collects file entries from an RPF archive, applying glob filters. + /// + public static List<(RpfFile rpf, RpfFileEntry entry)> CollectFiles( + RpfFile rpf, + string[]? filters, + bool recursive + ) + { + List<(RpfFile, RpfFileEntry)> files = []; + CollectFilesRecursive(rpf, filters, recursive, files); + return files; + } + + private static void CollectFilesRecursive( + RpfFile rpf, + string[]? filters, + bool recursive, + List<(RpfFile, RpfFileEntry)> files + ) + { + foreach (RpfEntry entry in rpf.AllEntries) + { + if (entry is RpfFileEntry fileEntry) + { + if (entry.NameLower.EndsWith(".rpf")) + continue; + + if (!Filter.Matches(entry.Path, filters)) + continue; + + files.Add((rpf, fileEntry)); + } + } + + if (recursive && rpf.Children != null) + { + foreach (RpfFile child in rpf.Children) + { + CollectFilesRecursive(child, filters, recursive, files); + } + } + } + + /// + /// Counts non-RPF files in the archive, optionally recursing into nested RPFs. + /// + public static int CountNonRpfFiles(RpfFile rpf, bool recursive) + { + int count = 0; + CountNonRpfFilesRecursive(rpf, recursive, ref count); + return count; + } + + private static void CountNonRpfFilesRecursive(RpfFile rpf, bool recursive, ref int count) + { + foreach (RpfEntry entry in rpf.AllEntries) + { + if (entry is RpfFileEntry && !entry.NameLower.EndsWith(".rpf")) + { + count++; + } + } + + if (recursive && rpf.Children != null) + { + foreach (RpfFile child in rpf.Children) + { + CountNonRpfFilesRecursive(child, recursive, ref count); + } + } + } + + /// + /// Returns the file type string for a given RPF file entry. + /// + public static string GetFileType(RpfFileEntry fileEntry) + { + return fileEntry switch + { + RpfResourceFileEntry => "resource", + RpfBinaryFileEntry => "binary", + _ => "unknown", + }; + } +} diff --git a/CodeWalker.sln b/CodeWalker.sln index 3e728f0e2..c714993e8 100644 --- a/CodeWalker.sln +++ b/CodeWalker.sln @@ -27,6 +27,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeWalker.ModManager", "Co EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeWalker.Gen9Converter", "CodeWalker.Gen9Converter\CodeWalker.Gen9Converter.csproj", "{C099F538-B5F6-4AAF-B877-B0835DE4EFA6}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodeWalker.Cli", "CodeWalker.Cli\CodeWalker.Cli.csproj", "{D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -156,6 +158,18 @@ Global {C099F538-B5F6-4AAF-B877-B0835DE4EFA6}.Release|x64.Build.0 = Release|Any CPU {C099F538-B5F6-4AAF-B877-B0835DE4EFA6}.Release|x86.ActiveCfg = Release|Any CPU {C099F538-B5F6-4AAF-B877-B0835DE4EFA6}.Release|x86.Build.0 = Release|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Debug|x64.ActiveCfg = Debug|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Debug|x64.Build.0 = Debug|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Debug|x86.ActiveCfg = Debug|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Debug|x86.Build.0 = Debug|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Release|Any CPU.Build.0 = Release|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Release|x64.ActiveCfg = Release|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Release|x64.Build.0 = Release|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Release|x86.ActiveCfg = Release|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From e7ad5a89dc8309f8d463d119ff12c0e3507de037 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:26:32 +0200 Subject: [PATCH 03/45] feat(cli): add the tree, gen9, pack and diff commands tree prints the archive as a directory tree, gen9 converts an archive to the Enhanced resource layout, pack builds an archive from a folder, and diff reports what changed between two archives. diff takes --si so the sizes it prints can use SI units like the rest, pack takes --force to overwrite an existing archive, and extract takes --no-overwrite to leave existing output alone. hash's JSON reports the signed and unsigned forms of each hash rather than only the unsigned one, since both spellings turn up in game files. --- CodeWalker.Cli/DiffHandler.cs | 434 ++++++++++++++++++++++++ CodeWalker.Cli/ExtractHandler.cs | 25 ++ CodeWalker.Cli/Gen9Handler.cs | 536 ++++++++++++++++++++++++++++++ CodeWalker.Cli/HashHandler.cs | 10 +- CodeWalker.Cli/Json/DiffResult.cs | 77 +++++ CodeWalker.Cli/Json/Gen9Result.cs | 50 +++ CodeWalker.Cli/Json/HashResult.cs | 4 +- CodeWalker.Cli/Json/PackResult.cs | 34 ++ CodeWalker.Cli/Json/TreeResult.cs | 53 +++ CodeWalker.Cli/ListHandler.cs | 2 +- CodeWalker.Cli/PackHandler.cs | 360 ++++++++++++++++++++ CodeWalker.Cli/Program.cs | 4 + CodeWalker.Cli/TreeHandler.cs | 375 +++++++++++++++++++++ 13 files changed, 1959 insertions(+), 5 deletions(-) create mode 100644 CodeWalker.Cli/DiffHandler.cs create mode 100644 CodeWalker.Cli/Gen9Handler.cs create mode 100644 CodeWalker.Cli/Json/DiffResult.cs create mode 100644 CodeWalker.Cli/Json/Gen9Result.cs create mode 100644 CodeWalker.Cli/Json/PackResult.cs create mode 100644 CodeWalker.Cli/Json/TreeResult.cs create mode 100644 CodeWalker.Cli/PackHandler.cs create mode 100644 CodeWalker.Cli/TreeHandler.cs diff --git a/CodeWalker.Cli/DiffHandler.cs b/CodeWalker.Cli/DiffHandler.cs new file mode 100644 index 000000000..17f76042b --- /dev/null +++ b/CodeWalker.Cli/DiffHandler.cs @@ -0,0 +1,434 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.IO; +using System.Text.Json; +using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; + +namespace CodeWalker.Cli; + +public record DiffOptions +{ + public required string LeftPath { get; init; } + public required string RightPath { get; init; } + public required string ExePath { get; init; } + public required bool Gen9 { get; init; } + public required bool Recursive { get; init; } + public required bool Verbose { get; init; } + public required bool Json { get; init; } + public required SizeFormat SizeFormat { get; init; } +} + +public static class DiffHandler +{ + public static Command CreateCommand() + { + // csharpier-ignore-start + Option leftOption = new("--left", "-l") + { + Description = "First RPF archive to compare", + Required = true, + }; + + Option rightOption = new("--right", "-r") + { + Description = "Second RPF archive to compare", + Required = true, + }; + + Option exeOption = new("--exe", "-e") + { + Description = "Path to the GTA V installation directory (containing GTA5.exe)", + Required = true, + }; + + Option gen9Option = new("--gen9", "-g") + { + Description = "Use GTA V Enhanced (Gen9) mode", + }; + + Option recursiveOption = new("--recursive", "-R") + { + Description = "Include nested RPFs in comparison", + }; + + Option verboseOption = new("--verbose", "-v") + { + Description = "Show unchanged files too", + }; + + Option jsonOption = new("--json") + { + Description = "Output results in JSON format", + }; + + Option siOption = new("--si") + { + Description = "Use SI units (1000-based: KB, MB) instead of IEC (1024-based: KiB, MiB)", + }; + // csharpier-ignore-end + + Command command = new("diff", "Compare two RPF archives") + { + leftOption, + rightOption, + exeOption, + gen9Option, + recursiveOption, + verboseOption, + jsonOption, + siOption, + }; + command.Aliases.Add("d"); + + command.SetAction(parseResult => + { + DiffOptions options = new() + { + LeftPath = parseResult.GetRequiredValue(leftOption).FullName, + RightPath = parseResult.GetRequiredValue(rightOption).FullName, + ExePath = parseResult.GetRequiredValue(exeOption).FullName, + Gen9 = parseResult.GetValue(gen9Option), + Recursive = parseResult.GetValue(recursiveOption), + Verbose = parseResult.GetValue(verboseOption), + Json = parseResult.GetValue(jsonOption), + SizeFormat = parseResult.GetValue(siOption) ? SizeFormat.SI : SizeFormat.IEC, + }; + return Execute(options); + }); + + return command; + } + + public static int Execute(DiffOptions options) + { + List errorMessages = []; + + Json.DiffResult result = new() + { + Success = false, + LeftRpf = options.LeftPath, + RightRpf = options.RightPath, + Added = [], + Removed = [], + Modified = [], + Unchanged = [], + Summary = new Json.DiffSummary + { + AddedCount = 0, + RemovedCount = 0, + ModifiedCount = 0, + UnchangedCount = 0, + }, + ErrorMessages = errorMessages, + }; + + // Validate both RPF files + string? leftError = RpfService.ValidateInputs( + options.LeftPath, + options.ExePath, + options.Gen9 + ); + if (leftError != null) + { + return ReportError(leftError, options, result); + } + + string? rightError = RpfService.ValidateInputs( + options.RightPath, + options.ExePath, + options.Gen9 + ); + if (rightError != null) + { + return ReportError(rightError, options, result); + } + + try + { + if (!options.Json) + { + Console.Error.WriteLine("Loading encryption keys..."); + } + RpfService.LoadKeys(options.ExePath, options.Gen9); + + if (!options.Json) + { + Console.Error.WriteLine($"Opening left RPF: {options.LeftPath}"); + } + + RpfFile leftRpf = RpfService.OpenRpf( + options.LeftPath, + onError: error => + { + if (!options.Json) + Console.Error.WriteLine($"Error: {error}"); + errorMessages.Add(error); + } + ); + + if (!options.Json) + { + Console.Error.WriteLine($"Opening right RPF: {options.RightPath}"); + } + + RpfFile rightRpf = RpfService.OpenRpf( + options.RightPath, + onError: error => + { + if (!options.Json) + Console.Error.WriteLine($"Error: {error}"); + errorMessages.Add(error); + } + ); + + // Collect files from both archives + List<(RpfFile rpf, RpfFileEntry entry)> leftFiles = RpfService.CollectFiles( + leftRpf, + null, + options.Recursive + ); + List<(RpfFile rpf, RpfFileEntry entry)> rightFiles = RpfService.CollectFiles( + rightRpf, + null, + options.Recursive + ); + + // Build dictionaries keyed by path + Dictionary leftDict = []; + foreach ((RpfFile rpf, RpfFileEntry entry) in leftFiles) + { + leftDict[entry.Path] = (rpf, entry); + } + + Dictionary rightDict = []; + foreach ((RpfFile rpf, RpfFileEntry entry) in rightFiles) + { + rightDict[entry.Path] = (rpf, entry); + } + + List added = []; + List removed = []; + List modified = []; + List unchanged = []; + + SizeFormat sizeFormat = options.SizeFormat; + + // Find removed and modified/unchanged + foreach (KeyValuePair kvp in leftDict) + { + string path = kvp.Key; + (RpfFile leftRpfRef, RpfFileEntry leftEntry) = kvp.Value; + + if (rightDict.TryGetValue(path, out (RpfFile rpf, RpfFileEntry entry) right)) + { + long leftSize = leftEntry.GetFileSize(); + long rightSize = right.entry.GetFileSize(); + string leftType = RpfService.GetFileType(leftEntry); + string rightType = RpfService.GetFileType(right.entry); + + bool isModified; + if (leftSize != rightSize || leftType != rightType) + { + isModified = true; + } + else + { + byte[]? leftData = leftRpfRef.ExtractFile(leftEntry); + byte[]? rightData = right.rpf.ExtractFile(right.entry); + isModified = !ContentEquals(leftData, rightData); + } + + if (isModified) + { + modified.Add( + new Json.DiffEntry + { + Path = path, + Name = leftEntry.Name, + Type = leftType, + LeftSize = leftSize, + RightSize = rightSize, + } + ); + } + else + { + unchanged.Add( + new Json.DiffEntry + { + Path = path, + Name = leftEntry.Name, + Type = leftType, + Size = leftSize, + SizeFormatted = sizeFormat.ToFormattedString(leftSize), + } + ); + } + } + else + { + long size = leftEntry.GetFileSize(); + removed.Add( + new Json.DiffEntry + { + Path = path, + Name = leftEntry.Name, + Type = RpfService.GetFileType(leftEntry), + Size = size, + SizeFormatted = sizeFormat.ToFormattedString(size), + } + ); + } + } + + // Find added + foreach (KeyValuePair kvp in rightDict) + { + if (!leftDict.ContainsKey(kvp.Key)) + { + long size = kvp.Value.entry.GetFileSize(); + added.Add( + new Json.DiffEntry + { + Path = kvp.Key, + Name = kvp.Value.entry.Name, + Type = RpfService.GetFileType(kvp.Value.entry), + Size = size, + SizeFormatted = sizeFormat.ToFormattedString(size), + } + ); + } + } + + // Sort alphabetically + added.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path)); + removed.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path)); + modified.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path)); + unchanged.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path)); + + Json.DiffSummary summary = new() + { + AddedCount = added.Count, + RemovedCount = removed.Count, + ModifiedCount = modified.Count, + UnchangedCount = unchanged.Count, + }; + + result = result with + { + Success = true, + Added = added, + Removed = removed, + Modified = modified, + Unchanged = unchanged, + Summary = summary, + }; + + if (options.Json) + { + Console.WriteLine( + JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + ); + } + else + { + if (added.Count > 0) + { + Console.WriteLine($"Added ({added.Count}):"); + foreach (Json.DiffEntry entry in added) + { + Console.WriteLine($" + {entry.Path}"); + } + Console.WriteLine(); + } + + if (removed.Count > 0) + { + Console.WriteLine($"Removed ({removed.Count}):"); + foreach (Json.DiffEntry entry in removed) + { + Console.WriteLine($" - {entry.Path}"); + } + Console.WriteLine(); + } + + if (modified.Count > 0) + { + Console.WriteLine($"Modified ({modified.Count}):"); + foreach (Json.DiffEntry entry in modified) + { + Console.WriteLine( + $" ~ {entry.Path} ({sizeFormat.ToFormattedString(entry.LeftSize ?? 0)} -> {sizeFormat.ToFormattedString(entry.RightSize ?? 0)})" + ); + } + Console.WriteLine(); + } + + if (options.Verbose && unchanged.Count > 0) + { + Console.WriteLine($"Unchanged ({unchanged.Count}):"); + foreach (Json.DiffEntry entry in unchanged) + { + Console.WriteLine($" = {entry.Path}"); + } + Console.WriteLine(); + } + + Console.Error.WriteLine( + $"Summary: {added.Count} added, {removed.Count} removed, {modified.Count} modified, {unchanged.Count} unchanged" + ); + } + + return 0; + } + catch (Exception ex) + { + return ReportError(ex.Message, options, result, options.Verbose ? ex.StackTrace : null); + } + } + + private static bool ContentEquals(byte[]? a, byte[]? b) + { + if (a == null && b == null) + return true; + if (a == null || b == null) + return false; + if (a.Length != b.Length) + return false; + for (int i = 0; i < a.Length; i++) + { + if (a[i] != b[i]) + return false; + } + return true; + } + + private static int ReportError( + string message, + DiffOptions options, + Json.DiffResult result, + string? stackTrace = null + ) + { + if (options.Json) + { + result = result with + { + Success = false, + ErrorMessages = [.. result.ErrorMessages, message], + }; + Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); + } + else + { + Console.Error.WriteLine($"Error: {message}"); + if (stackTrace != null) + { + Console.Error.WriteLine(stackTrace); + } + } + return 1; + } +} diff --git a/CodeWalker.Cli/ExtractHandler.cs b/CodeWalker.Cli/ExtractHandler.cs index bdc6e3183..e588090f7 100644 --- a/CodeWalker.Cli/ExtractHandler.cs +++ b/CodeWalker.Cli/ExtractHandler.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.IO; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; @@ -14,6 +15,7 @@ public record ExtractOptions public required RpfOptions Rpf { get; init; } public required string? OutputPath { get; init; } public required bool DryRun { get; init; } + public required bool NoOverwrite { get; init; } public required bool Progress { get; init; } } @@ -34,6 +36,11 @@ public static Command CreateCommand() Description = "Show what would be extracted without actually extracting", }; + Option noOverwriteOption = new("--no-overwrite") + { + Description = "Skip existing files instead of overwriting", + }; + Option progressOption = new("--progress", "-P") { Description = "Show progress bar during extraction", @@ -44,6 +51,7 @@ public static Command CreateCommand() { outputOption, dryRunOption, + noOverwriteOption, progressOption, }; rpfOpts.AddTo(command); @@ -56,6 +64,7 @@ public static Command CreateCommand() Rpf = rpfOpts.Parse(parseResult), OutputPath = parseResult.GetValue(outputOption)?.FullName, DryRun = parseResult.GetValue(dryRunOption), + NoOverwrite = parseResult.GetValue(noOverwriteOption), Progress = parseResult.GetValue(progressOption), }; return Execute(options); @@ -157,6 +166,7 @@ public static int Execute(ExtractOptions options) // Count non-RPF files that were excluded by filters int totalNonRpfFiles = RpfService.CountNonRpfFiles(rpf, options.Rpf.Recursive); int skipped = totalNonRpfFiles - filesToExtract.Count; + int overwriteSkipped = 0; // Process files in parallel, storing results by index to preserve order (bool success, Json.FileEntry? jsonEntry, string? errorMessage)[] results = new ( @@ -217,6 +227,19 @@ public static int Execute(ExtractOptions options) } results[i] = (true, jsonEntry, null); } + else if (options.NoOverwrite && File.Exists(outputPath)) + { + Interlocked.Increment(ref overwriteSkipped); + if (options.Rpf.Verbose && !options.Rpf.Json && !options.Progress) + { + lock (consoleLock) + { + Console.Error.WriteLine( + $"Skipping (exists): {fileEntry.Path}" + ); + } + } + } else { if (!string.IsNullOrEmpty(fileDir) && !Directory.Exists(fileDir)) @@ -299,6 +322,8 @@ public static int Execute(ExtractOptions options) } } + skipped += overwriteSkipped; + result = result with { Extracted = extracted, diff --git a/CodeWalker.Cli/Gen9Handler.cs b/CodeWalker.Cli/Gen9Handler.cs new file mode 100644 index 000000000..f4ab37933 --- /dev/null +++ b/CodeWalker.Cli/Gen9Handler.cs @@ -0,0 +1,536 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.IO; +using System.Text.Json; +using CodeWalker.Cli.Helpers; +using CodeWalker.Core.Utils; +using CodeWalker.GameFiles; + +namespace CodeWalker.Cli; + +public record Gen9Options +{ + public required string InputPath { get; init; } + public required string OutputPath { get; init; } + public required string ExePath { get; init; } + public required bool NoRecurse { get; init; } + public required bool NoOverwrite { get; init; } + public required bool SkipUnconverted { get; init; } + public required bool Progress { get; init; } + public required bool Verbose { get; init; } + public required bool Json { get; init; } +} + +public static class Gen9Handler +{ + public static Command CreateCommand() + { + // csharpier-ignore-start + Option inputOption = new("--input", "-i") + { + Description = "Input folder containing files to convert", + Required = true, + }; + + Option outputOption = new("--output", "-o") + { + Description = "Output folder for converted files", + Required = true, + }; + + Option exeOption = new("--exe", "-e") + { + Description = "Path to the GTA V installation directory (containing GTA5.exe)", + Required = true, + }; + + Option noRecurseOption = new("--no-recurse") + { + Description = "Skip subfolders (default: recurse)", + }; + + Option noOverwriteOption = new("--no-overwrite") + { + Description = "Skip existing output files", + }; + + Option skipUnconvertedOption = new("--skip-unconverted") + { + Description = "Don't copy files that don't need conversion", + }; + + Option progressOption = new("--progress", "-P") + { + Description = "Show progress bar", + }; + + Option verboseOption = new("--verbose", "-v") + { + Description = "Show per-file status", + }; + + Option jsonOption = new("--json") + { + Description = "Output results in JSON format", + }; + // csharpier-ignore-end + + Command command = new("gen9", "Convert files between standard and enhanced (Gen9) formats") + { + inputOption, + outputOption, + exeOption, + noRecurseOption, + noOverwriteOption, + skipUnconvertedOption, + progressOption, + verboseOption, + jsonOption, + }; + command.Aliases.Add("g"); + + command.SetAction(parseResult => + { + Gen9Options options = new() + { + InputPath = parseResult.GetRequiredValue(inputOption).FullName, + OutputPath = parseResult.GetRequiredValue(outputOption).FullName, + ExePath = parseResult.GetRequiredValue(exeOption).FullName, + NoRecurse = parseResult.GetValue(noRecurseOption), + NoOverwrite = parseResult.GetValue(noOverwriteOption), + SkipUnconverted = parseResult.GetValue(skipUnconvertedOption), + Progress = parseResult.GetValue(progressOption), + Verbose = parseResult.GetValue(verboseOption), + Json = parseResult.GetValue(jsonOption), + }; + return Execute(options); + }); + + return command; + } + + public static int Execute(Gen9Options options) + { + List files = []; + List errorMessages = []; + + Json.Gen9Result result = new() + { + Success = false, + InputFolder = options.InputPath, + OutputFolder = options.OutputPath, + TotalFiles = 0, + Converted = 0, + Skipped = 0, + Copied = 0, + Errors = 0, + Files = files, + ErrorMessages = errorMessages, + }; + + if (!Directory.Exists(options.InputPath)) + { + return ReportError($"Input folder not found: {options.InputPath}", options, result); + } + + if ( + string.Equals( + Path.GetFullPath(options.InputPath), + Path.GetFullPath(options.OutputPath), + StringComparison.OrdinalIgnoreCase + ) + ) + { + return ReportError( + "Input folder and Output folder must be different.", + options, + result + ); + } + + string exeFile = "GTA5_Enhanced.exe"; + if (!File.Exists(Path.Combine(options.ExePath, exeFile))) + { + return ReportError($"{exeFile} not found in: {options.ExePath}", options, result); + } + + try + { + if (!options.Json) + { + Console.Error.WriteLine("Loading encryption keys..."); + } + GTA5Keys.LoadFromPath(options.ExePath, true); + + bool previousGen9 = RpfManager.IsGen9; + RpfManager.IsGen9 = true; + + try + { + if (!Directory.Exists(options.OutputPath)) + { + Directory.CreateDirectory(options.OutputPath); + } + + string inputFolder = options.InputPath; + if (!inputFolder.EndsWith(Path.DirectorySeparatorChar.ToString())) + { + inputFolder += Path.DirectorySeparatorChar; + } + + SearchOption searchOption = options.NoRecurse + ? SearchOption.TopDirectoryOnly + : SearchOption.AllDirectories; + + string[] allPaths = Directory.GetFileSystemEntries(inputFolder, "*", searchOption); + + // Filter to files only + List filePaths = []; + foreach (string p in allPaths) + { + if (File.Exists(p)) + { + filePaths.Add(p); + } + } + + if (!options.Json) + { + Console.Error.WriteLine( + $"Found {filePaths.Count} files in {options.InputPath}" + ); + } + + int converted = 0; + int skipped = 0; + int copied = 0; + int errors = 0; + bool copyUnconverted = !options.SkipUnconverted; + + using ( + ProgressBar progress = new(filePaths.Count, options.Progress && !options.Json) + ) + { + foreach (string path in filePaths) + { + string relPath = path.Substring(inputFolder.Length); + string outPath = Path.Combine(options.OutputPath, relPath); + + try + { + if (options.NoOverwrite && File.Exists(outPath)) + { + skipped++; + files.Add( + new Json.Gen9FileEntry + { + Path = relPath, + Status = "skipped", + Message = "Output file already exists", + } + ); + if (options.Verbose && !options.Json) + { + Console.Error.WriteLine($"{relPath} - skipped (exists)"); + } + progress.Increment(relPath); + continue; + } + + string? outDir = Path.GetDirectoryName(outPath); + if (!string.IsNullOrEmpty(outDir) && !Directory.Exists(outDir)) + { + Directory.CreateDirectory(outDir); + } + + string ext = Path.GetExtension(path).ToLowerInvariant(); + + if (ext == ".rpf") + { + ProcessRpfFile( + path, + outPath, + relPath, + options, + files, + errorMessages, + ref converted, + ref skipped, + ref errors + ); + } + else + { + byte[] dataIn = File.ReadAllBytes(path); + byte[] dataOut = Gen9Converter.TryConvert( + dataIn, + ext, + msg => + { + if (options.Verbose && !options.Json) + Console.Error.WriteLine(msg); + }, + relPath, + copyUnconverted, + out bool wasConverted + ); + + if (wasConverted) + { + File.WriteAllBytes(outPath, dataOut); + converted++; + files.Add( + new Json.Gen9FileEntry + { + Path = relPath, + Status = "converted", + } + ); + } + else if (dataOut != null) + { + File.WriteAllBytes(outPath, dataOut); + copied++; + files.Add( + new Json.Gen9FileEntry { Path = relPath, Status = "copied" } + ); + } + else + { + skipped++; + files.Add( + new Json.Gen9FileEntry + { + Path = relPath, + Status = "skipped", + } + ); + } + } + + progress.Increment(relPath); + } + catch (Exception ex) + { + errors++; + string errorMsg = $"Error processing {relPath}: {ex.Message}"; + errorMessages.Add(errorMsg); + files.Add( + new Json.Gen9FileEntry + { + Path = relPath, + Status = "error", + Message = ex.Message, + } + ); + if (!options.Json) + { + Console.Error.WriteLine($"Error: {errorMsg}"); + } + progress.Increment(); + } + } + } + + result = result with + { + Success = errors == 0, + TotalFiles = filePaths.Count, + Converted = converted, + Skipped = skipped, + Copied = copied, + Errors = errors, + }; + + if (options.Json) + { + Console.WriteLine( + JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + ); + } + else + { + Console.Error.WriteLine(); + Console.Error.WriteLine( + $"Conversion complete: {converted} converted, {copied} copied, {skipped} skipped, {errors} errors" + ); + } + + return errors > 0 ? 1 : 0; + } + finally + { + RpfManager.IsGen9 = previousGen9; + } + } + catch (Exception ex) + { + return ReportError(ex.Message, options, result, options.Verbose ? ex.StackTrace : null); + } + } + + private static void ProcessRpfFile( + string inputPath, + string outputPath, + string relPath, + Gen9Options options, + List files, + List errorMessages, + ref int converted, + ref int skipped, + ref int errors + ) + { + if (options.Verbose && !options.Json) + { + Console.Error.WriteLine($"{relPath} - Converting RPF contents..."); + } + + if (File.Exists(outputPath)) + { + File.Delete(outputPath); + } + File.Copy(inputPath, outputPath); + + RpfFile rpf = new(outputPath, relPath); + rpf.ScanStructure( + status => + { + if (options.Verbose && !options.Json) + Console.Error.WriteLine(status); + }, + error => + { + if (!options.Json) + Console.Error.WriteLine($"Error: {error}"); + errorMessages.Add(error); + } + ); + + // Build list of all RPFs (children first, then parents) + List rpfList = []; + Stack rpfStack = new(); + rpfStack.Push(rpf); + while (rpfStack.Count > 0) + { + RpfFile current = rpfStack.Pop(); + if (current.Children != null) + { + foreach (RpfFile child in current.Children) + { + rpfStack.Push(child); + } + } + rpfList.Add(current); + } + rpfList.Reverse(); + + HashSet changedParents = []; + + foreach (RpfFile currentRpf in rpfList) + { + if (currentRpf.AllEntries == null) + continue; + + bool changed = changedParents.Contains(currentRpf); + + List resourceEntries = []; + foreach (RpfEntry entry in currentRpf.AllEntries) + { + if (entry is RpfResourceFileEntry rfe) + resourceEntries.Add(rfe); + } + resourceEntries.Sort((a, b) => a.FileOffset.CompareTo(b.FileOffset)); + + foreach (RpfResourceFileEntry rfe in resourceEntries) + { + if (!Gen9Converter.RequiresConversion(rfe)) + continue; + + RpfDirectoryEntry dir = rfe.Parent; + string name = rfe.Name; + string type = Path.GetExtension(rfe.NameLower); + + byte[] dataIn = currentRpf.ExtractFile(rfe); + dataIn = ResourceBuilder.Compress(dataIn); + dataIn = ResourceBuilder.AddResourceHeader(rfe, dataIn); + + byte[] dataOut = Gen9Converter.TryConvert( + dataIn, + type, + msg => + { + if (options.Verbose && !options.Json) + Console.Error.WriteLine(msg); + }, + rfe.Path, + false, + out bool wasConverted + ); + + if (!wasConverted || dataOut == null) + { + errors++; + string errorMsg = $"{rfe.Path} - unable to convert"; + errorMessages.Add(errorMsg); + files.Add( + new Json.Gen9FileEntry + { + Path = rfe.Path, + Status = "error", + Message = "Unable to convert", + } + ); + continue; + } + + RpfFile.CreateFile(dir, name, dataOut, true); + converted++; + files.Add(new Json.Gen9FileEntry { Path = rfe.Path, Status = "converted" }); + changed = true; + } + + if (changed) + { + if (options.Verbose && !options.Json) + { + Console.Error.WriteLine($"{currentRpf.Path} - Defragmenting"); + } + RpfFile.Defragment(currentRpf, null, false); + + if (currentRpf.Parent != null) + { + changedParents.Add(currentRpf.Parent); + } + } + } + } + + private static int ReportError( + string message, + Gen9Options options, + Json.Gen9Result result, + string? stackTrace = null + ) + { + if (options.Json) + { + result = result with + { + Success = false, + ErrorMessages = [.. result.ErrorMessages, message], + }; + Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); + } + else + { + Console.Error.WriteLine($"Error: {message}"); + if (stackTrace != null) + { + Console.Error.WriteLine(stackTrace); + } + } + return 1; + } +} diff --git a/CodeWalker.Cli/HashHandler.cs b/CodeWalker.Cli/HashHandler.cs index 18c395b33..a152e8cb2 100644 --- a/CodeWalker.Cli/HashHandler.cs +++ b/CodeWalker.Cli/HashHandler.cs @@ -65,11 +65,13 @@ public static int Execute(HashOptions options) { List hashes = []; + List errorMessages = []; + Json.HashResult result = new() { Success = false, Hashes = hashes, - ErrorMessage = null, + ErrorMessages = errorMessages, }; // Validate encoding @@ -140,7 +142,11 @@ private static int ReportError(string message, HashOptions options, Json.HashRes { if (options.Json) { - result = result with { Success = false, ErrorMessage = message }; + result = result with + { + Success = false, + ErrorMessages = [.. result.ErrorMessages, message], + }; Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); } else diff --git a/CodeWalker.Cli/Json/DiffResult.cs b/CodeWalker.Cli/Json/DiffResult.cs new file mode 100644 index 000000000..c8d86a679 --- /dev/null +++ b/CodeWalker.Cli/Json/DiffResult.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace CodeWalker.Cli.Json; + +public record DiffResult +{ + [JsonPropertyName("success")] + public required bool Success { get; init; } + + [JsonPropertyName("leftRpf")] + public required string LeftRpf { get; init; } + + [JsonPropertyName("rightRpf")] + public required string RightRpf { get; init; } + + [JsonPropertyName("added")] + public required IReadOnlyList Added { get; init; } + + [JsonPropertyName("removed")] + public required IReadOnlyList Removed { get; init; } + + [JsonPropertyName("modified")] + public required IReadOnlyList Modified { get; init; } + + [JsonPropertyName("unchanged")] + public required IReadOnlyList Unchanged { get; init; } + + [JsonPropertyName("summary")] + public required DiffSummary Summary { get; init; } + + [JsonPropertyName("errorMessages")] + public required IReadOnlyList ErrorMessages { get; init; } +} + +public record DiffEntry +{ + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("size")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Size { get; init; } + + [JsonPropertyName("sizeFormatted")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SizeFormatted { get; init; } + + [JsonPropertyName("leftSize")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? LeftSize { get; init; } + + [JsonPropertyName("rightSize")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? RightSize { get; init; } +} + +public record DiffSummary +{ + [JsonPropertyName("addedCount")] + public required int AddedCount { get; init; } + + [JsonPropertyName("removedCount")] + public required int RemovedCount { get; init; } + + [JsonPropertyName("modifiedCount")] + public required int ModifiedCount { get; init; } + + [JsonPropertyName("unchangedCount")] + public required int UnchangedCount { get; init; } +} diff --git a/CodeWalker.Cli/Json/Gen9Result.cs b/CodeWalker.Cli/Json/Gen9Result.cs new file mode 100644 index 000000000..c6f398ba7 --- /dev/null +++ b/CodeWalker.Cli/Json/Gen9Result.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace CodeWalker.Cli.Json; + +public record Gen9Result +{ + [JsonPropertyName("success")] + public required bool Success { get; init; } + + [JsonPropertyName("inputFolder")] + public required string InputFolder { get; init; } + + [JsonPropertyName("outputFolder")] + public required string OutputFolder { get; init; } + + [JsonPropertyName("totalFiles")] + public required int TotalFiles { get; init; } + + [JsonPropertyName("converted")] + public required int Converted { get; init; } + + [JsonPropertyName("skipped")] + public required int Skipped { get; init; } + + [JsonPropertyName("copied")] + public required int Copied { get; init; } + + [JsonPropertyName("errors")] + public required int Errors { get; init; } + + [JsonPropertyName("files")] + public required IReadOnlyList Files { get; init; } + + [JsonPropertyName("errorMessages")] + public required IReadOnlyList ErrorMessages { get; init; } +} + +public record Gen9FileEntry +{ + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("status")] + public required string Status { get; init; } + + [JsonPropertyName("message")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Message { get; init; } +} diff --git a/CodeWalker.Cli/Json/HashResult.cs b/CodeWalker.Cli/Json/HashResult.cs index 745edc9b3..b96b1773f 100644 --- a/CodeWalker.Cli/Json/HashResult.cs +++ b/CodeWalker.Cli/Json/HashResult.cs @@ -11,8 +11,8 @@ public record HashResult [JsonPropertyName("hashes")] public required IReadOnlyList Hashes { get; init; } - [JsonPropertyName("errorMessage")] - public required string? ErrorMessage { get; init; } + [JsonPropertyName("errorMessages")] + public required IReadOnlyList ErrorMessages { get; init; } } public record HashEntry diff --git a/CodeWalker.Cli/Json/PackResult.cs b/CodeWalker.Cli/Json/PackResult.cs new file mode 100644 index 000000000..11aaec6ca --- /dev/null +++ b/CodeWalker.Cli/Json/PackResult.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace CodeWalker.Cli.Json; + +public record PackResult +{ + [JsonPropertyName("success")] + public required bool Success { get; init; } + + [JsonPropertyName("inputDir")] + public required string InputDir { get; init; } + + [JsonPropertyName("outputFile")] + public required string OutputFile { get; init; } + + [JsonPropertyName("totalFiles")] + public required int TotalFiles { get; init; } + + [JsonPropertyName("totalDirs")] + public required int TotalDirs { get; init; } + + [JsonPropertyName("totalSize")] + public required long TotalSize { get; init; } + + [JsonPropertyName("totalSizeFormatted")] + public required string TotalSizeFormatted { get; init; } + + [JsonPropertyName("errors")] + public required int Errors { get; init; } + + [JsonPropertyName("errorMessages")] + public required IReadOnlyList ErrorMessages { get; init; } +} diff --git a/CodeWalker.Cli/Json/TreeResult.cs b/CodeWalker.Cli/Json/TreeResult.cs new file mode 100644 index 000000000..d71ab6af7 --- /dev/null +++ b/CodeWalker.Cli/Json/TreeResult.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace CodeWalker.Cli.Json; + +public record TreeResult +{ + [JsonPropertyName("success")] + public required bool Success { get; init; } + + [JsonPropertyName("rpfFile")] + public required string RpfFile { get; init; } + + [JsonPropertyName("totalFiles")] + public required int TotalFiles { get; init; } + + [JsonPropertyName("totalDirs")] + public required int TotalDirs { get; init; } + + [JsonPropertyName("root")] + public TreeNode? Root { get; init; } + + [JsonPropertyName("errorMessages")] + public required IReadOnlyList ErrorMessages { get; init; } +} + +public record TreeNode +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("size")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Size { get; init; } + + [JsonPropertyName("sizeFormatted")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SizeFormatted { get; init; } + + [JsonPropertyName("fileType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FileType { get; init; } + + [JsonPropertyName("children")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? Children { get; init; } +} diff --git a/CodeWalker.Cli/ListHandler.cs b/CodeWalker.Cli/ListHandler.cs index ad6f81bd1..1d084b287 100644 --- a/CodeWalker.Cli/ListHandler.cs +++ b/CodeWalker.Cli/ListHandler.cs @@ -38,7 +38,7 @@ public static int Execute(RpfOptions options) RpfFile = null!, TotalFiles = 0, TotalSize = 0, - TotalSizeFormatted = null!, + TotalSizeFormatted = "0 B", NestedRpfCount = 0, Files = files, ErrorMessages = errorMessages, diff --git a/CodeWalker.Cli/PackHandler.cs b/CodeWalker.Cli/PackHandler.cs new file mode 100644 index 000000000..98fdcdfa5 --- /dev/null +++ b/CodeWalker.Cli/PackHandler.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.IO; +using System.Text.Json; +using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; + +namespace CodeWalker.Cli; + +public record PackOptions +{ + public required string InputPath { get; init; } + public required string OutputPath { get; init; } + public required string ExePath { get; init; } + public required bool Gen9 { get; init; } + public required bool Force { get; init; } + public required bool Progress { get; init; } + public required bool Verbose { get; init; } + public required bool Json { get; init; } + public required SizeFormat SizeFormat { get; init; } +} + +public static class PackHandler +{ + public static Command CreateCommand() + { + // csharpier-ignore-start + Option inputOption = new("--input", "-i") + { + Description = "Source directory of loose files to pack", + Required = true, + }; + + Option outputOption = new("--output", "-o") + { + Description = "Output RPF file path", + Required = true, + }; + + Option exeOption = new("--exe", "-e") + { + Description = "Path to the GTA V installation directory (containing GTA5.exe)", + Required = true, + }; + + Option gen9Option = new("--gen9", "-g") + { + Description = "Use GTA V Enhanced (Gen9) mode", + }; + + Option forceOption = new("--force", "-F") + { + Description = "Overwrite existing output file", + }; + + Option progressOption = new("--progress", "-P") + { + Description = "Show progress bar", + }; + + Option verboseOption = new("--verbose", "-v") + { + Description = "Show per-file status", + }; + + Option jsonOption = new("--json") + { + Description = "Output results in JSON format", + }; + + Option siOption = new("--si") + { + Description = "Use SI units (1000-based: KB, MB) instead of IEC (1024-based: KiB, MiB)", + }; + // csharpier-ignore-end + + Command command = new("pack", "Create an RPF archive from a directory of loose files") + { + inputOption, + outputOption, + exeOption, + gen9Option, + forceOption, + progressOption, + verboseOption, + jsonOption, + siOption, + }; + command.Aliases.Add("p"); + + command.SetAction(parseResult => + { + PackOptions options = new() + { + InputPath = parseResult.GetRequiredValue(inputOption).FullName, + OutputPath = parseResult.GetRequiredValue(outputOption).FullName, + ExePath = parseResult.GetRequiredValue(exeOption).FullName, + Gen9 = parseResult.GetValue(gen9Option), + Force = parseResult.GetValue(forceOption), + Progress = parseResult.GetValue(progressOption), + Verbose = parseResult.GetValue(verboseOption), + Json = parseResult.GetValue(jsonOption), + SizeFormat = parseResult.GetValue(siOption) ? SizeFormat.SI : SizeFormat.IEC, + }; + return Execute(options); + }); + + return command; + } + + public static int Execute(PackOptions options) + { + List errorMessages = []; + + Json.PackResult result = new() + { + Success = false, + InputDir = options.InputPath, + OutputFile = options.OutputPath, + TotalFiles = 0, + TotalDirs = 0, + TotalSize = 0, + TotalSizeFormatted = "0 B", + Errors = 0, + ErrorMessages = errorMessages, + }; + + if (!Directory.Exists(options.InputPath)) + { + return ReportError($"Input directory not found: {options.InputPath}", options, result); + } + + if (File.Exists(options.OutputPath)) + { + if (!options.Force) + { + return ReportError( + $"Output file already exists: {options.OutputPath}. Use --force to overwrite.", + options, + result + ); + } + File.Delete(options.OutputPath); + } + + string exeFile = options.Gen9 ? "GTA5_Enhanced.exe" : "GTA5.exe"; + if (!File.Exists(Path.Combine(options.ExePath, exeFile))) + { + return ReportError($"{exeFile} not found in: {options.ExePath}", options, result); + } + + try + { + if (!options.Json) + { + Console.Error.WriteLine("Loading encryption keys..."); + } + RpfService.LoadKeys(options.ExePath, options.Gen9); + + // Count files for progress bar + string[] allFiles = Directory.GetFiles( + options.InputPath, + "*", + SearchOption.AllDirectories + ); + + if (!options.Json) + { + Console.Error.WriteLine( + $"Packing {allFiles.Length} files from {options.InputPath}" + ); + } + + // Create the output directory if needed + string? outputDir = Path.GetDirectoryName(options.OutputPath); + if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir)) + { + Directory.CreateDirectory(outputDir); + } + + string outputFolder = outputDir ?? Directory.GetCurrentDirectory(); + string outputFileName = Path.GetFileName(options.OutputPath); + + RpfFile rpf = RpfFile.CreateNew(outputFolder, outputFileName); + + if (!options.Json) + { + Console.Error.WriteLine($"Created RPF: {options.OutputPath}"); + } + + int totalFiles = 0; + int totalDirs = 0; + long totalSize = 0; + int errors = 0; + + using (ProgressBar progress = new(allFiles.Length, options.Progress && !options.Json)) + { + AddDirectoryContents( + rpf.Root, + options.InputPath, + options, + progress, + errorMessages, + ref totalFiles, + ref totalDirs, + ref totalSize, + ref errors + ); + } + + if (!options.Json) + { + Console.Error.WriteLine("Defragmenting archive..."); + } + RpfFile.Defragment(rpf); + + SizeFormat sizeFormat = options.SizeFormat; + + result = result with + { + Success = errors == 0, + TotalFiles = totalFiles, + TotalDirs = totalDirs, + TotalSize = totalSize, + TotalSizeFormatted = sizeFormat.ToFormattedString(totalSize), + Errors = errors, + }; + + if (options.Json) + { + Console.WriteLine( + JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + ); + } + else + { + Console.Error.WriteLine(); + Console.Error.WriteLine( + $"Pack complete: {totalFiles} files, {totalDirs} directories, {sizeFormat.ToFormattedString(totalSize)}, {errors} errors" + ); + } + + return errors > 0 ? 1 : 0; + } + catch (Exception ex) + { + return ReportError(ex.Message, options, result, options.Verbose ? ex.StackTrace : null); + } + } + + private static void AddDirectoryContents( + RpfDirectoryEntry parentDir, + string fsDir, + PackOptions options, + ProgressBar progress, + List errorMessages, + ref int totalFiles, + ref int totalDirs, + ref long totalSize, + ref int errors + ) + { + // Add subdirectories first + foreach (string subDirPath in Directory.GetDirectories(fsDir)) + { + string dirName = Path.GetFileName(subDirPath); + try + { + if (options.Verbose && !options.Json) + { + Console.Error.WriteLine($"Creating directory: {dirName}"); + } + + RpfDirectoryEntry newDir = RpfFile.CreateDirectory(parentDir, dirName); + totalDirs++; + + AddDirectoryContents( + newDir, + subDirPath, + options, + progress, + errorMessages, + ref totalFiles, + ref totalDirs, + ref totalSize, + ref errors + ); + } + catch (Exception ex) + { + errors++; + string errorMsg = $"Error creating directory {dirName}: {ex.Message}"; + errorMessages.Add(errorMsg); + if (!options.Json) + { + Console.Error.WriteLine($"Error: {errorMsg}"); + } + } + } + + // Add files + foreach (string filePath in Directory.GetFiles(fsDir)) + { + string fileName = Path.GetFileName(filePath); + try + { + byte[] data = File.ReadAllBytes(filePath); + + if (options.Verbose && !options.Json) + { + Console.Error.WriteLine($"Adding file: {fileName} ({data.Length} bytes)"); + } + + RpfFile.CreateFile(parentDir, fileName, data); + totalFiles++; + totalSize += data.Length; + progress.Increment(fileName); + } + catch (Exception ex) + { + errors++; + string errorMsg = $"Error adding file {fileName}: {ex.Message}"; + errorMessages.Add(errorMsg); + if (!options.Json) + { + Console.Error.WriteLine($"Error: {errorMsg}"); + } + progress.Increment(); + } + } + } + + private static int ReportError( + string message, + PackOptions options, + Json.PackResult result, + string? stackTrace = null + ) + { + if (options.Json) + { + result = result with + { + Success = false, + ErrorMessages = [.. result.ErrorMessages, message], + }; + Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); + } + else + { + Console.Error.WriteLine($"Error: {message}"); + if (stackTrace != null) + { + Console.Error.WriteLine(stackTrace); + } + } + return 1; + } +} diff --git a/CodeWalker.Cli/Program.cs b/CodeWalker.Cli/Program.cs index 5f44ac0a7..5a31fe718 100644 --- a/CodeWalker.Cli/Program.cs +++ b/CodeWalker.Cli/Program.cs @@ -6,6 +6,10 @@ ExtractHandler.CreateCommand(), ListHandler.CreateCommand(), HashHandler.CreateCommand(), + TreeHandler.CreateCommand(), + Gen9Handler.CreateCommand(), + PackHandler.CreateCommand(), + DiffHandler.CreateCommand(), }; return rootCommand.Parse(args).Invoke(); diff --git a/CodeWalker.Cli/TreeHandler.cs b/CodeWalker.Cli/TreeHandler.cs new file mode 100644 index 000000000..65bdef031 --- /dev/null +++ b/CodeWalker.Cli/TreeHandler.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.IO; +using System.Text.Json; +using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; + +namespace CodeWalker.Cli; + +public record TreeOptions +{ + public required RpfOptions Rpf { get; init; } + public required int Depth { get; init; } +} + +public static class TreeHandler +{ + public static Command CreateCommand() + { + RpfCommandOptions rpfOpts = new(); + // csharpier-ignore-start + Option depthOption = new("--depth", "-d") + { + Description = "Maximum depth to display (default: unlimited)", + DefaultValueFactory = _ => -1, + }; + // csharpier-ignore-end + + Command command = new("tree", "Display a visual tree of the RPF directory structure") + { + depthOption, + }; + rpfOpts.AddTo(command); + command.Aliases.Add("t"); + + command.SetAction(parseResult => + { + TreeOptions options = new() + { + Rpf = rpfOpts.Parse(parseResult), + Depth = parseResult.GetValue(depthOption), + }; + return Execute(options); + }); + + return command; + } + + public static int Execute(TreeOptions options) + { + List errorMessages = []; + + Json.TreeResult result = new() + { + Success = false, + RpfFile = null!, + TotalFiles = 0, + TotalDirs = 0, + ErrorMessages = errorMessages, + }; + + string? validationError = RpfService.ValidateInputs( + options.Rpf.RpfPath, + options.Rpf.ExePath, + options.Rpf.Gen9 + ); + if (validationError != null) + { + return ReportError(validationError, options, result); + } + + try + { + if (!options.Rpf.Json) + { + Console.Error.WriteLine("Loading encryption keys..."); + } + RpfService.LoadKeys(options.Rpf.ExePath, options.Rpf.Gen9); + + if (!options.Rpf.Json) + { + Console.Error.WriteLine($"Opening RPF: {options.Rpf.RpfPath}"); + } + + RpfFile rpf = RpfService.OpenRpf( + options.Rpf.RpfPath, + onStatus: status => + { + if (options.Rpf.Verbose && !options.Rpf.Json) + Console.Error.WriteLine(status); + }, + onError: error => + { + if (!options.Rpf.Json) + Console.Error.WriteLine($"Error: {error}"); + errorMessages.Add(error); + } + ); + + int totalFiles = 0; + int totalDirs = 0; + + if (options.Rpf.Json) + { + Json.TreeNode rootNode = BuildTreeNode( + rpf.Root, + rpf, + options, + 0, + ref totalFiles, + ref totalDirs + ); + + result = result with + { + Success = true, + RpfFile = options.Rpf.RpfPath, + TotalFiles = totalFiles, + TotalDirs = totalDirs, + Root = rootNode, + }; + + Console.WriteLine( + JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + ); + } + else + { + Console.WriteLine(Path.GetFileName(options.Rpf.RpfPath)); + PrintTree(rpf.Root, rpf, options, "", 0, ref totalFiles, ref totalDirs); + + Console.Error.WriteLine(); + Console.Error.WriteLine($"{totalDirs} directories, {totalFiles} files"); + } + + return 0; + } + catch (Exception ex) + { + return ReportError( + ex.Message, + options, + result, + options.Rpf.Verbose ? ex.StackTrace : null + ); + } + } + + private static void PrintTree( + RpfDirectoryEntry dir, + RpfFile rpf, + TreeOptions options, + string prefix, + int depth, + ref int totalFiles, + ref int totalDirs + ) + { + if (options.Depth >= 0 && depth > options.Depth) + return; + + List<(string name, bool isDir, RpfEntry entry, RpfFile? childRpf)> items = CollectChildren( + dir, + rpf, + options + ); + + for (int i = 0; i < items.Count; i++) + { + bool isLast = i == items.Count - 1; + string connector = isLast ? "\u2514\u2500\u2500 " : "\u251c\u2500\u2500 "; + string childPrefix = prefix + (isLast ? " " : "\u2502 "); + + (string name, bool isDirectory, RpfEntry entry, RpfFile? childRpf) = items[i]; + + if (isDirectory) + { + totalDirs++; + string display = name + "/"; + Console.WriteLine($"{prefix}{connector}{display}"); + + if (entry is RpfDirectoryEntry subDir) + { + PrintTree( + subDir, + childRpf ?? rpf, + options, + childPrefix, + depth + 1, + ref totalFiles, + ref totalDirs + ); + } + } + else + { + totalFiles++; + if (options.Rpf.Verbose && entry is RpfFileEntry fileEntry) + { + long size = fileEntry.GetFileSize(); + string sizeStr = options.Rpf.SizeFormat.ToFormattedString(size); + string fileType = RpfService.GetFileType(fileEntry); + string versionStr = ""; + if (fileEntry is RpfResourceFileEntry rfe) + { + versionStr = $" v{rfe.Version}"; + } + Console.WriteLine( + $"{prefix}{connector}{name} ({sizeStr}, {fileType}{versionStr})" + ); + } + else + { + Console.WriteLine($"{prefix}{connector}{name}"); + } + } + } + } + + private static Json.TreeNode BuildTreeNode( + RpfDirectoryEntry dir, + RpfFile rpf, + TreeOptions options, + int depth, + ref int totalFiles, + ref int totalDirs + ) + { + List children = []; + + if (options.Depth < 0 || depth < options.Depth) + { + List<(string name, bool isDir, RpfEntry entry, RpfFile? childRpf)> items = + CollectChildren(dir, rpf, options); + + foreach ((string name, bool isDirectory, RpfEntry entry, RpfFile? childRpf) in items) + { + if (isDirectory) + { + totalDirs++; + if (entry is RpfDirectoryEntry subDir) + { + children.Add( + BuildTreeNode( + subDir, + childRpf ?? rpf, + options, + depth + 1, + ref totalFiles, + ref totalDirs + ) + ); + } + } + else + { + totalFiles++; + long? size = null; + string? sizeFormatted = null; + string? fileType = null; + + if (entry is RpfFileEntry fileEntry) + { + size = fileEntry.GetFileSize(); + sizeFormatted = options.Rpf.SizeFormat.ToFormattedString(size.Value); + fileType = RpfService.GetFileType(fileEntry); + } + + children.Add( + new Json.TreeNode + { + Name = name, + Path = entry.Path, + Type = "file", + Size = size, + SizeFormatted = sizeFormatted, + FileType = fileType, + } + ); + } + } + } + + return new Json.TreeNode + { + Name = dir.Name ?? Path.GetFileName(rpf.FilePath), + Path = dir.Path ?? rpf.Path, + Type = "dir", + Children = children, + }; + } + + private static List<( + string name, + bool isDir, + RpfEntry entry, + RpfFile? childRpf + )> CollectChildren(RpfDirectoryEntry dir, RpfFile rpf, TreeOptions options) + { + List<(string name, bool isDir, RpfEntry entry, RpfFile? childRpf)> items = []; + + // Add subdirectories + if (dir.Directories != null) + { + foreach (RpfDirectoryEntry subDir in dir.Directories) + { + items.Add((subDir.Name, true, subDir, null)); + } + } + + // Add nested RPFs as directories if recursive + if (options.Rpf.Recursive && dir.Files != null) + { + foreach (RpfFileEntry fileEntry in dir.Files) + { + if (fileEntry.NameLower.EndsWith(".rpf") && rpf.Children != null) + { + foreach (RpfFile child in rpf.Children) + { + if (child.Name == fileEntry.Name && child.Root != null) + { + items.Add((fileEntry.Name, true, child.Root, child)); + break; + } + } + } + } + } + + // Add files (non-RPF, matching filters) + if (dir.Files != null) + { + foreach (RpfFileEntry fileEntry in dir.Files) + { + if (fileEntry.NameLower.EndsWith(".rpf")) + continue; + + if (!Filter.Matches(fileEntry.Path, options.Rpf.Filters)) + continue; + + items.Add((fileEntry.Name, false, fileEntry, null)); + } + } + + return items; + } + + private static int ReportError( + string message, + TreeOptions options, + Json.TreeResult result, + string? stackTrace = null + ) + { + if (options.Rpf.Json) + { + result = result with + { + Success = false, + ErrorMessages = [.. result.ErrorMessages, message], + }; + Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); + } + else + { + Console.Error.WriteLine($"Error: {message}"); + if (stackTrace != null) + { + Console.Error.WriteLine(stackTrace); + } + } + return 1; + } +} From 35ce37f93e2de69884f2771d365dec96d1584b10 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:26:32 +0200 Subject: [PATCH 04/45] refactor(cli): share the error results and fix the glob syntax Every handler wrote out its own error result record and its own "print the message, pick an exit code" block, all of them the same except for the result type. The result records share a BaseResult, and RpfService.ReportError does the printing, so a failure path is one call. The glob translation only understood * and ?, and mapped * to '.*', so '*.ytd' matched across directory separators and there was no way to say "any depth". * and ? stop at a separator now, ** crosses them, and '**/' matches zero or more directory segments. extract counted a file it had skipped under --no-overwrite as extracted. --- CodeWalker.Cli/DiffHandler.cs | 74 +++++-------------- CodeWalker.Cli/ExtractHandler.cs | 105 +++++++-------------------- CodeWalker.Cli/Gen9Handler.cs | 50 +++++-------- CodeWalker.Cli/HashHandler.cs | 24 +----- CodeWalker.Cli/Helpers/Filter.cs | 15 +++- CodeWalker.Cli/Json/DiffResult.cs | 8 +- CodeWalker.Cli/Json/ExtractResult.cs | 8 +- CodeWalker.Cli/Json/FileEntry.cs | 4 + CodeWalker.Cli/Json/Gen9Result.cs | 8 +- CodeWalker.Cli/Json/HashResult.cs | 8 +- CodeWalker.Cli/Json/ListResult.cs | 8 +- CodeWalker.Cli/Json/PackResult.cs | 8 +- CodeWalker.Cli/Json/TreeResult.cs | 8 +- CodeWalker.Cli/ListHandler.cs | 83 ++++----------------- CodeWalker.Cli/PackHandler.cs | 50 +++++-------- CodeWalker.Cli/RpfService.cs | 97 +++++++++++++++++++++++++ CodeWalker.Cli/TreeHandler.cs | 67 +++-------------- 17 files changed, 232 insertions(+), 393 deletions(-) diff --git a/CodeWalker.Cli/DiffHandler.cs b/CodeWalker.Cli/DiffHandler.cs index 17f76042b..25bbfab6f 100644 --- a/CodeWalker.Cli/DiffHandler.cs +++ b/CodeWalker.Cli/DiffHandler.cs @@ -124,7 +124,7 @@ public static int Execute(DiffOptions options) ErrorMessages = errorMessages, }; - // Validate both RPF files + // Validate both RPF files exist before loading keys string? leftError = RpfService.ValidateInputs( options.LeftPath, options.ExePath, @@ -132,7 +132,7 @@ public static int Execute(DiffOptions options) ); if (leftError != null) { - return ReportError(leftError, options, result); + return RpfService.ReportError(leftError, options.Json, result); } string? rightError = RpfService.ValidateInputs( @@ -142,45 +142,27 @@ public static int Execute(DiffOptions options) ); if (rightError != null) { - return ReportError(rightError, options, result); + return RpfService.ReportError(rightError, options.Json, result); } try { if (!options.Json) - { Console.Error.WriteLine("Loading encryption keys..."); - } RpfService.LoadKeys(options.ExePath, options.Gen9); - if (!options.Json) - { - Console.Error.WriteLine($"Opening left RPF: {options.LeftPath}"); - } - RpfFile leftRpf = RpfService.OpenRpf( options.LeftPath, - onError: error => - { - if (!options.Json) - Console.Error.WriteLine($"Error: {error}"); - errorMessages.Add(error); - } + options.Verbose, + options.Json, + errorMessages ); - if (!options.Json) - { - Console.Error.WriteLine($"Opening right RPF: {options.RightPath}"); - } - RpfFile rightRpf = RpfService.OpenRpf( options.RightPath, - onError: error => - { - if (!options.Json) - Console.Error.WriteLine($"Error: {error}"); - errorMessages.Add(error); - } + options.Verbose, + options.Json, + errorMessages ); // Collect files from both archives @@ -385,7 +367,12 @@ public static int Execute(DiffOptions options) } catch (Exception ex) { - return ReportError(ex.Message, options, result, options.Verbose ? ex.StackTrace : null); + return RpfService.ReportError( + ex.Message, + options.Json, + result, + options.Verbose ? ex.StackTrace : null + ); } } @@ -397,38 +384,15 @@ private static bool ContentEquals(byte[]? a, byte[]? b) return false; if (a.Length != b.Length) return false; +#if NET5_0_OR_GREATER + return a.AsSpan().SequenceEqual(b); +#else for (int i = 0; i < a.Length; i++) { if (a[i] != b[i]) return false; } return true; - } - - private static int ReportError( - string message, - DiffOptions options, - Json.DiffResult result, - string? stackTrace = null - ) - { - if (options.Json) - { - result = result with - { - Success = false, - ErrorMessages = [.. result.ErrorMessages, message], - }; - Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); - } - else - { - Console.Error.WriteLine($"Error: {message}"); - if (stackTrace != null) - { - Console.Error.WriteLine(stackTrace); - } - } - return 1; +#endif } } diff --git a/CodeWalker.Cli/ExtractHandler.cs b/CodeWalker.Cli/ExtractHandler.cs index e588090f7..93d0f262c 100644 --- a/CodeWalker.Cli/ExtractHandler.cs +++ b/CodeWalker.Cli/ExtractHandler.cs @@ -81,8 +81,8 @@ public static int Execute(ExtractOptions options) Json.ExtractResult result = new() { Success = false, - RpfFile = null!, - OutputDir = null!, + RpfFile = options.Rpf.RpfPath, + OutputDir = options.OutputPath ?? Directory.GetCurrentDirectory(), TotalFiles = 0, Extracted = 0, Skipped = 0, @@ -92,57 +92,27 @@ public static int Execute(ExtractOptions options) ErrorMessages = errorMessages, }; - string? validationError = RpfService.ValidateInputs( + string? initError = RpfService.ValidateAndLoadKeys( options.Rpf.RpfPath, options.Rpf.ExePath, - options.Rpf.Gen9 + options.Rpf.Gen9, + options.Rpf.Json ); - if (validationError != null) + if (initError != null) { - return ReportError(validationError, options, result); + return RpfService.ReportError(initError, options.Rpf.Json, result); } try { - if (!options.Rpf.Json) - { - Console.Error.WriteLine("Loading encryption keys..."); - } - RpfService.LoadKeys(options.Rpf.ExePath, options.Rpf.Gen9); - - if (!options.Rpf.Json) - { - Console.Error.WriteLine($"Opening RPF: {options.Rpf.RpfPath}"); - } - RpfFile rpf = RpfService.OpenRpf( options.Rpf.RpfPath, - onStatus: status => - { - if (options.Rpf.Verbose && !options.Rpf.Json) - Console.Error.WriteLine(status); - }, - onError: error => - { - if (!options.Rpf.Json) - Console.Error.WriteLine($"Error: {error}"); - errorMessages.Add(error); - } + options.Rpf.Verbose, + options.Rpf.Json, + errorMessages ); - if (!options.Rpf.Json) - { - Console.Error.WriteLine( - $"Found {rpf.GrandTotalFileCount} files in {rpf.GrandTotalRpfCount} archive(s)" - ); - } - - result = result with - { - RpfFile = options.Rpf.RpfPath, - OutputDir = options.OutputPath ?? Directory.GetCurrentDirectory(), - TotalFiles = rpf.GrandTotalFileCount, - }; + result = result with { TotalFiles = rpf.GrandTotalFileCount }; if (!options.Rpf.Json && options.DryRun) { @@ -225,7 +195,7 @@ public static int Execute(ExtractOptions options) Console.WriteLine($"Would extract: {fileEntry.Path}"); } } - results[i] = (true, jsonEntry, null); + results[i] = (true, jsonEntry with { Status = "dry_run" }, null); } else if (options.NoOverwrite && File.Exists(outputPath)) { @@ -239,6 +209,7 @@ public static int Execute(ExtractOptions options) ); } } + results[i] = (false, jsonEntry with { Status = "skipped" }, null); } else { @@ -259,7 +230,14 @@ public static int Execute(ExtractOptions options) if (data != null) { File.WriteAllBytes(outputPath, data); - results[i] = (true, jsonEntry, null); + results[i] = ( + true, + jsonEntry with + { + Status = "extracted", + }, + null + ); } else { @@ -310,12 +288,12 @@ public static int Execute(ExtractOptions options) foreach (var (success, jsonEntry, errorMessage) in results) { if (success) - { extracted++; - if (jsonEntry != null) - files.Add(jsonEntry); - } - else if (errorMessage != null) + + if (jsonEntry != null) + files.Add(jsonEntry); + + if (!success && errorMessage != null) { errors++; errorMessages.Add(errorMessage); @@ -351,39 +329,12 @@ public static int Execute(ExtractOptions options) } catch (Exception ex) { - return ReportError( + return RpfService.ReportError( ex.Message, - options, + options.Rpf.Json, result, options.Rpf.Verbose ? ex.StackTrace : null ); } } - - private static int ReportError( - string message, - ExtractOptions options, - Json.ExtractResult result, - string? stackTrace = null - ) - { - if (options.Rpf.Json) - { - result = result with - { - Success = false, - ErrorMessages = [.. result.ErrorMessages, message], - }; - Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); - } - else - { - Console.Error.WriteLine($"Error: {message}"); - if (stackTrace != null) - { - Console.Error.WriteLine(stackTrace); - } - } - return 1; - } } diff --git a/CodeWalker.Cli/Gen9Handler.cs b/CodeWalker.Cli/Gen9Handler.cs index f4ab37933..4395aa571 100644 --- a/CodeWalker.Cli/Gen9Handler.cs +++ b/CodeWalker.Cli/Gen9Handler.cs @@ -131,7 +131,11 @@ public static int Execute(Gen9Options options) if (!Directory.Exists(options.InputPath)) { - return ReportError($"Input folder not found: {options.InputPath}", options, result); + return RpfService.ReportError( + $"Input folder not found: {options.InputPath}", + options.Json, + result + ); } if ( @@ -142,9 +146,9 @@ public static int Execute(Gen9Options options) ) ) { - return ReportError( + return RpfService.ReportError( "Input folder and Output folder must be different.", - options, + options.Json, result ); } @@ -152,7 +156,11 @@ public static int Execute(Gen9Options options) string exeFile = "GTA5_Enhanced.exe"; if (!File.Exists(Path.Combine(options.ExePath, exeFile))) { - return ReportError($"{exeFile} not found in: {options.ExePath}", options, result); + return RpfService.ReportError( + $"{exeFile} not found in: {options.ExePath}", + options.Json, + result + ); } try @@ -366,7 +374,12 @@ out bool wasConverted } catch (Exception ex) { - return ReportError(ex.Message, options, result, options.Verbose ? ex.StackTrace : null); + return RpfService.ReportError( + ex.Message, + options.Json, + result, + options.Verbose ? ex.StackTrace : null + ); } } @@ -506,31 +519,4 @@ out bool wasConverted } } } - - private static int ReportError( - string message, - Gen9Options options, - Json.Gen9Result result, - string? stackTrace = null - ) - { - if (options.Json) - { - result = result with - { - Success = false, - ErrorMessages = [.. result.ErrorMessages, message], - }; - Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); - } - else - { - Console.Error.WriteLine($"Error: {message}"); - if (stackTrace != null) - { - Console.Error.WriteLine(stackTrace); - } - } - return 1; - } } diff --git a/CodeWalker.Cli/HashHandler.cs b/CodeWalker.Cli/HashHandler.cs index a152e8cb2..e092b4650 100644 --- a/CodeWalker.Cli/HashHandler.cs +++ b/CodeWalker.Cli/HashHandler.cs @@ -88,9 +88,9 @@ public static int Execute(HashOptions options) encoding = JenkHashInputEncoding.ASCII; break; default: - return ReportError( + return RpfService.ReportError( $"Unknown encoding: {options.Encoding}. Use 'utf-8' or 'ascii'.", - options, + options.Json, result ); } @@ -134,25 +134,7 @@ public static int Execute(HashOptions options) } catch (Exception ex) { - return ReportError(ex.Message, options, result); + return RpfService.ReportError(ex.Message, options.Json, result); } } - - private static int ReportError(string message, HashOptions options, Json.HashResult result) - { - if (options.Json) - { - result = result with - { - Success = false, - ErrorMessages = [.. result.ErrorMessages, message], - }; - Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); - } - else - { - Console.Error.WriteLine($"Error: {message}"); - } - return 1; - } } diff --git a/CodeWalker.Cli/Helpers/Filter.cs b/CodeWalker.Cli/Helpers/Filter.cs index febb71027..f4ebc3b64 100644 --- a/CodeWalker.Cli/Helpers/Filter.cs +++ b/CodeWalker.Cli/Helpers/Filter.cs @@ -68,10 +68,17 @@ private static bool MatchesGlob(string input, string pattern) { // Convert glob pattern to regex // Escape all regex special chars except * and ? - string regexPattern = Regex - .Escape(p) - .Replace("\\*", ".*") // * matches any sequence of characters - .Replace("\\?", "."); // ? matches any single character + string regexPattern = Regex.Escape(p); + + // Handle ** (globstar) before * — order matters + // **/ matches zero or more directory segments + regexPattern = regexPattern.Replace("\\*\\*/", "(.*/)?"); + // standalone ** matches any characters including / + regexPattern = regexPattern.Replace("\\*\\*", ".*"); + // * matches any characters except / (single path segment) + regexPattern = regexPattern.Replace("\\*", "[^/]*"); + // ? matches any single character except / + regexPattern = regexPattern.Replace("\\?", "[^/]"); // Patterns with path separators match at any path boundary; // filename-only patterns are anchored to the full filename. diff --git a/CodeWalker.Cli/Json/DiffResult.cs b/CodeWalker.Cli/Json/DiffResult.cs index c8d86a679..f347e8641 100644 --- a/CodeWalker.Cli/Json/DiffResult.cs +++ b/CodeWalker.Cli/Json/DiffResult.cs @@ -3,11 +3,8 @@ namespace CodeWalker.Cli.Json; -public record DiffResult +public record DiffResult : BaseResult { - [JsonPropertyName("success")] - public required bool Success { get; init; } - [JsonPropertyName("leftRpf")] public required string LeftRpf { get; init; } @@ -28,9 +25,6 @@ public record DiffResult [JsonPropertyName("summary")] public required DiffSummary Summary { get; init; } - - [JsonPropertyName("errorMessages")] - public required IReadOnlyList ErrorMessages { get; init; } } public record DiffEntry diff --git a/CodeWalker.Cli/Json/ExtractResult.cs b/CodeWalker.Cli/Json/ExtractResult.cs index dda5ced67..638b7c7bd 100644 --- a/CodeWalker.Cli/Json/ExtractResult.cs +++ b/CodeWalker.Cli/Json/ExtractResult.cs @@ -3,11 +3,8 @@ namespace CodeWalker.Cli.Json; -public record ExtractResult +public record ExtractResult : BaseResult { - [JsonPropertyName("success")] - public required bool Success { get; init; } - [JsonPropertyName("rpfFile")] public required string RpfFile { get; init; } @@ -31,7 +28,4 @@ public record ExtractResult [JsonPropertyName("files")] public required IReadOnlyList Files { get; init; } - - [JsonPropertyName("errorMessages")] - public required IReadOnlyList ErrorMessages { get; init; } } diff --git a/CodeWalker.Cli/Json/FileEntry.cs b/CodeWalker.Cli/Json/FileEntry.cs index c21b61cbd..c14e9aeb0 100644 --- a/CodeWalker.Cli/Json/FileEntry.cs +++ b/CodeWalker.Cli/Json/FileEntry.cs @@ -21,4 +21,8 @@ public record FileEntry [JsonPropertyName("extension")] public required string Extension { get; init; } + + [JsonPropertyName("status")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Status { get; init; } } diff --git a/CodeWalker.Cli/Json/Gen9Result.cs b/CodeWalker.Cli/Json/Gen9Result.cs index c6f398ba7..02e87a75b 100644 --- a/CodeWalker.Cli/Json/Gen9Result.cs +++ b/CodeWalker.Cli/Json/Gen9Result.cs @@ -3,11 +3,8 @@ namespace CodeWalker.Cli.Json; -public record Gen9Result +public record Gen9Result : BaseResult { - [JsonPropertyName("success")] - public required bool Success { get; init; } - [JsonPropertyName("inputFolder")] public required string InputFolder { get; init; } @@ -31,9 +28,6 @@ public record Gen9Result [JsonPropertyName("files")] public required IReadOnlyList Files { get; init; } - - [JsonPropertyName("errorMessages")] - public required IReadOnlyList ErrorMessages { get; init; } } public record Gen9FileEntry diff --git a/CodeWalker.Cli/Json/HashResult.cs b/CodeWalker.Cli/Json/HashResult.cs index b96b1773f..2d724409a 100644 --- a/CodeWalker.Cli/Json/HashResult.cs +++ b/CodeWalker.Cli/Json/HashResult.cs @@ -3,16 +3,10 @@ namespace CodeWalker.Cli.Json; -public record HashResult +public record HashResult : BaseResult { - [JsonPropertyName("success")] - public required bool Success { get; init; } - [JsonPropertyName("hashes")] public required IReadOnlyList Hashes { get; init; } - - [JsonPropertyName("errorMessages")] - public required IReadOnlyList ErrorMessages { get; init; } } public record HashEntry diff --git a/CodeWalker.Cli/Json/ListResult.cs b/CodeWalker.Cli/Json/ListResult.cs index d63ee742e..ac583efb6 100644 --- a/CodeWalker.Cli/Json/ListResult.cs +++ b/CodeWalker.Cli/Json/ListResult.cs @@ -3,11 +3,8 @@ namespace CodeWalker.Cli.Json; -public record ListResult +public record ListResult : BaseResult { - [JsonPropertyName("success")] - public required bool Success { get; init; } - [JsonPropertyName("rpfFile")] public required string RpfFile { get; init; } @@ -25,7 +22,4 @@ public record ListResult [JsonPropertyName("files")] public required IReadOnlyList Files { get; init; } - - [JsonPropertyName("errorMessages")] - public required IReadOnlyList ErrorMessages { get; init; } } diff --git a/CodeWalker.Cli/Json/PackResult.cs b/CodeWalker.Cli/Json/PackResult.cs index 11aaec6ca..a412240c0 100644 --- a/CodeWalker.Cli/Json/PackResult.cs +++ b/CodeWalker.Cli/Json/PackResult.cs @@ -3,11 +3,8 @@ namespace CodeWalker.Cli.Json; -public record PackResult +public record PackResult : BaseResult { - [JsonPropertyName("success")] - public required bool Success { get; init; } - [JsonPropertyName("inputDir")] public required string InputDir { get; init; } @@ -28,7 +25,4 @@ public record PackResult [JsonPropertyName("errors")] public required int Errors { get; init; } - - [JsonPropertyName("errorMessages")] - public required IReadOnlyList ErrorMessages { get; init; } } diff --git a/CodeWalker.Cli/Json/TreeResult.cs b/CodeWalker.Cli/Json/TreeResult.cs index d71ab6af7..fb11388cc 100644 --- a/CodeWalker.Cli/Json/TreeResult.cs +++ b/CodeWalker.Cli/Json/TreeResult.cs @@ -3,11 +3,8 @@ namespace CodeWalker.Cli.Json; -public record TreeResult +public record TreeResult : BaseResult { - [JsonPropertyName("success")] - public required bool Success { get; init; } - [JsonPropertyName("rpfFile")] public required string RpfFile { get; init; } @@ -19,9 +16,6 @@ public record TreeResult [JsonPropertyName("root")] public TreeNode? Root { get; init; } - - [JsonPropertyName("errorMessages")] - public required IReadOnlyList ErrorMessages { get; init; } } public record TreeNode diff --git a/CodeWalker.Cli/ListHandler.cs b/CodeWalker.Cli/ListHandler.cs index 1d084b287..d621a2336 100644 --- a/CodeWalker.Cli/ListHandler.cs +++ b/CodeWalker.Cli/ListHandler.cs @@ -35,7 +35,7 @@ public static int Execute(RpfOptions options) Json.ListResult result = new() { Success = false, - RpfFile = null!, + RpfFile = options.RpfPath, TotalFiles = 0, TotalSize = 0, TotalSizeFormatted = "0 B", @@ -44,56 +44,27 @@ public static int Execute(RpfOptions options) ErrorMessages = errorMessages, }; - string? validationError = RpfService.ValidateInputs( + string? initError = RpfService.ValidateAndLoadKeys( options.RpfPath, options.ExePath, - options.Gen9 + options.Gen9, + options.Json ); - if (validationError != null) + if (initError != null) { - return ReportError(validationError, options, result); + return RpfService.ReportError(initError, options.Json, result); } try { - if (!options.Json) - { - Console.Error.WriteLine("Loading encryption keys..."); - } - RpfService.LoadKeys(options.ExePath, options.Gen9); - - if (!options.Json) - { - Console.Error.WriteLine($"Opening RPF: {options.RpfPath}"); - } - RpfFile rpf = RpfService.OpenRpf( options.RpfPath, - onStatus: status => - { - if (options.Verbose && !options.Json) - Console.Error.WriteLine(status); - }, - onError: error => - { - if (!options.Json) - Console.Error.WriteLine($"Error: {error}"); - errorMessages.Add(error); - } + options.Verbose, + options.Json, + errorMessages ); - if (!options.Json) - { - Console.Error.WriteLine( - $"Found {rpf.GrandTotalFileCount} files in {rpf.GrandTotalRpfCount} archive(s)" - ); - } - - result = result with - { - RpfFile = options.RpfPath, - NestedRpfCount = rpf.GrandTotalRpfCount, - }; + result = result with { NestedRpfCount = rpf.GrandTotalRpfCount }; if (!options.Json) { @@ -193,34 +164,12 @@ public static int Execute(RpfOptions options) } catch (Exception ex) { - return ReportError(ex.Message, options, result, options.Verbose ? ex.StackTrace : null); - } - } - - private static int ReportError( - string message, - RpfOptions options, - Json.ListResult result, - string? stackTrace = null - ) - { - if (options.Json) - { - result = result with - { - Success = false, - ErrorMessages = [.. result.ErrorMessages, message], - }; - Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); - } - else - { - Console.Error.WriteLine($"Error: {message}"); - if (stackTrace != null) - { - Console.Error.WriteLine(stackTrace); - } + return RpfService.ReportError( + ex.Message, + options.Json, + result, + options.Verbose ? ex.StackTrace : null + ); } - return 1; } } diff --git a/CodeWalker.Cli/PackHandler.cs b/CodeWalker.Cli/PackHandler.cs index 98fdcdfa5..37b4e6f82 100644 --- a/CodeWalker.Cli/PackHandler.cs +++ b/CodeWalker.Cli/PackHandler.cs @@ -128,16 +128,20 @@ public static int Execute(PackOptions options) if (!Directory.Exists(options.InputPath)) { - return ReportError($"Input directory not found: {options.InputPath}", options, result); + return RpfService.ReportError( + $"Input directory not found: {options.InputPath}", + options.Json, + result + ); } if (File.Exists(options.OutputPath)) { if (!options.Force) { - return ReportError( + return RpfService.ReportError( $"Output file already exists: {options.OutputPath}. Use --force to overwrite.", - options, + options.Json, result ); } @@ -147,7 +151,11 @@ public static int Execute(PackOptions options) string exeFile = options.Gen9 ? "GTA5_Enhanced.exe" : "GTA5.exe"; if (!File.Exists(Path.Combine(options.ExePath, exeFile))) { - return ReportError($"{exeFile} not found in: {options.ExePath}", options, result); + return RpfService.ReportError( + $"{exeFile} not found in: {options.ExePath}", + options.Json, + result + ); } try @@ -245,7 +253,12 @@ ref errors } catch (Exception ex) { - return ReportError(ex.Message, options, result, options.Verbose ? ex.StackTrace : null); + return RpfService.ReportError( + ex.Message, + options.Json, + result, + options.Verbose ? ex.StackTrace : null + ); } } @@ -330,31 +343,4 @@ ref errors } } } - - private static int ReportError( - string message, - PackOptions options, - Json.PackResult result, - string? stackTrace = null - ) - { - if (options.Json) - { - result = result with - { - Success = false, - ErrorMessages = [.. result.ErrorMessages, message], - }; - Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); - } - else - { - Console.Error.WriteLine($"Error: {message}"); - if (stackTrace != null) - { - Console.Error.WriteLine(stackTrace); - } - } - return 1; - } } diff --git a/CodeWalker.Cli/RpfService.cs b/CodeWalker.Cli/RpfService.cs index 1ece33e15..9c4429b82 100644 --- a/CodeWalker.Cli/RpfService.cs +++ b/CodeWalker.Cli/RpfService.cs @@ -2,11 +2,23 @@ using System.Collections.Generic; using System.IO; using System.Text.Json; +using System.Text.Json.Serialization; using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; namespace CodeWalker.Cli; +public abstract record BaseResult +{ + [JsonPropertyName("success")] + [JsonPropertyOrder(-1)] + public required bool Success { get; init; } + + [JsonPropertyName("errorMessages")] + [JsonPropertyOrder(100)] + public required IReadOnlyList ErrorMessages { get; init; } +} + public static class RpfService { public static readonly JsonSerializerOptions JsonSerializerOptions = new() @@ -140,4 +152,89 @@ public static string GetFileType(RpfFileEntry fileEntry) _ => "unknown", }; } + + /// + /// Validates inputs, loads encryption keys, and prints status to stderr. + /// Returns an error message on failure, or null on success. + /// + public static string? ValidateAndLoadKeys(string rpfPath, string exePath, bool gen9, bool json) + { + string? error = ValidateInputs(rpfPath, exePath, gen9); + if (error != null) + return error; + + if (!json) + Console.Error.WriteLine("Loading encryption keys..."); + LoadKeys(exePath, gen9); + + return null; + } + + /// + /// Opens an RPF file with standard verbose/json output handling. + /// + public static RpfFile OpenRpf( + string rpfPath, + bool verbose, + bool json, + List errorMessages + ) + { + if (!json) + Console.Error.WriteLine($"Opening RPF: {rpfPath}"); + + RpfFile rpf = OpenRpf( + rpfPath, + onStatus: status => + { + if (verbose && !json) + Console.Error.WriteLine(status); + }, + onError: error => + { + if (!json) + Console.Error.WriteLine($"Error: {error}"); + errorMessages.Add(error); + } + ); + + if (!json) + Console.Error.WriteLine( + $"Found {rpf.GrandTotalFileCount} files in {rpf.GrandTotalRpfCount} archive(s)" + ); + + return rpf; + } + + /// + /// Reports an error in JSON or text format and returns exit code 1. + /// The with expression preserves the runtime (derived) type, and + /// serialises using that type so all properties are included. + /// + public static int ReportError( + string message, + bool json, + BaseResult result, + string? stackTrace = null + ) + { + if (json) + { + BaseResult errorResult = result with + { + Success = false, + ErrorMessages = [.. result.ErrorMessages, message], + }; + Console.WriteLine( + JsonSerializer.Serialize(errorResult, errorResult.GetType(), JsonSerializerOptions) + ); + } + else + { + Console.Error.WriteLine($"Error: {message}"); + if (stackTrace != null) + Console.Error.WriteLine(stackTrace); + } + return 1; + } } diff --git a/CodeWalker.Cli/TreeHandler.cs b/CodeWalker.Cli/TreeHandler.cs index 65bdef031..cd69f3119 100644 --- a/CodeWalker.Cli/TreeHandler.cs +++ b/CodeWalker.Cli/TreeHandler.cs @@ -54,48 +54,30 @@ public static int Execute(TreeOptions options) Json.TreeResult result = new() { Success = false, - RpfFile = null!, + RpfFile = options.Rpf.RpfPath, TotalFiles = 0, TotalDirs = 0, ErrorMessages = errorMessages, }; - string? validationError = RpfService.ValidateInputs( + string? initError = RpfService.ValidateAndLoadKeys( options.Rpf.RpfPath, options.Rpf.ExePath, - options.Rpf.Gen9 + options.Rpf.Gen9, + options.Rpf.Json ); - if (validationError != null) + if (initError != null) { - return ReportError(validationError, options, result); + return RpfService.ReportError(initError, options.Rpf.Json, result); } try { - if (!options.Rpf.Json) - { - Console.Error.WriteLine("Loading encryption keys..."); - } - RpfService.LoadKeys(options.Rpf.ExePath, options.Rpf.Gen9); - - if (!options.Rpf.Json) - { - Console.Error.WriteLine($"Opening RPF: {options.Rpf.RpfPath}"); - } - RpfFile rpf = RpfService.OpenRpf( options.Rpf.RpfPath, - onStatus: status => - { - if (options.Rpf.Verbose && !options.Rpf.Json) - Console.Error.WriteLine(status); - }, - onError: error => - { - if (!options.Rpf.Json) - Console.Error.WriteLine($"Error: {error}"); - errorMessages.Add(error); - } + options.Rpf.Verbose, + options.Rpf.Json, + errorMessages ); int totalFiles = 0; @@ -138,9 +120,9 @@ ref totalDirs } catch (Exception ex) { - return ReportError( + return RpfService.ReportError( ex.Message, - options, + options.Rpf.Json, result, options.Rpf.Verbose ? ex.StackTrace : null ); @@ -345,31 +327,4 @@ ref totalDirs return items; } - - private static int ReportError( - string message, - TreeOptions options, - Json.TreeResult result, - string? stackTrace = null - ) - { - if (options.Rpf.Json) - { - result = result with - { - Success = false, - ErrorMessages = [.. result.ErrorMessages, message], - }; - Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); - } - else - { - Console.Error.WriteLine($"Error: {message}"); - if (stackTrace != null) - { - Console.Error.WriteLine(stackTrace); - } - } - return 1; - } } From 73f33d8dc7d9b44aa1b9777424b808f2a0de70eb Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:26:32 +0200 Subject: [PATCH 05/45] feat(cli): add the export command export turns game files into formats other tools can read: xml for metadata, textures for .ytd dictionaries as DDS, audio for .awc streams, and text for .gxt2 localization. Each subcommand takes an alias, so 'export t' and 'export ytd' both reach textures. The four subcommands differ only in how they turn one entry into output files. Everything around that -- validating the arguments, opening the archive, collecting the entries a filter selects, running them across threads, aggregating the results and reporting them -- is ExportService, and a subcommand supplies a delegate. CommonOptions holds the options that are not specific to reading an archive, so the option records stop repeating each other. --- CodeWalker.Cli/CommonOptions.cs | 73 +++++ CodeWalker.Cli/DiffHandler.cs | 255 +++++++++--------- CodeWalker.Cli/ExportAudioHandler.cs | 94 +++++++ CodeWalker.Cli/ExportHandler.cs | 75 ++++++ CodeWalker.Cli/ExportService.cs | 283 ++++++++++++++++++++ CodeWalker.Cli/ExportTextHandler.cs | 79 ++++++ CodeWalker.Cli/ExportTexturesHandler.cs | 86 ++++++ CodeWalker.Cli/ExportXmlHandler.cs | 69 +++++ CodeWalker.Cli/ExtractHandler.cs | 52 ++-- CodeWalker.Cli/Gen9Handler.cs | 339 +++++++++++++++--------- CodeWalker.Cli/HashHandler.cs | 31 ++- CodeWalker.Cli/Helpers/Filter.cs | 29 +- CodeWalker.Cli/Json/ExportResult.cs | 53 ++++ CodeWalker.Cli/ListHandler.cs | 44 +-- CodeWalker.Cli/PackHandler.cs | 131 ++++----- CodeWalker.Cli/Program.cs | 1 + CodeWalker.Cli/RpfOptions.cs | 49 +--- CodeWalker.Cli/RpfService.cs | 33 ++- CodeWalker.Cli/TreeHandler.cs | 29 +- 19 files changed, 1355 insertions(+), 450 deletions(-) create mode 100644 CodeWalker.Cli/CommonOptions.cs create mode 100644 CodeWalker.Cli/ExportAudioHandler.cs create mode 100644 CodeWalker.Cli/ExportHandler.cs create mode 100644 CodeWalker.Cli/ExportService.cs create mode 100644 CodeWalker.Cli/ExportTextHandler.cs create mode 100644 CodeWalker.Cli/ExportTexturesHandler.cs create mode 100644 CodeWalker.Cli/ExportXmlHandler.cs create mode 100644 CodeWalker.Cli/Json/ExportResult.cs diff --git a/CodeWalker.Cli/CommonOptions.cs b/CodeWalker.Cli/CommonOptions.cs new file mode 100644 index 000000000..33aa17af0 --- /dev/null +++ b/CodeWalker.Cli/CommonOptions.cs @@ -0,0 +1,73 @@ +using System; +using System.CommandLine; +using System.IO; +using CodeWalker.Cli.Helpers; + +namespace CodeWalker.Cli; + +public record CommonOptions +{ + public required string ExePath { get; init; } + public required bool Verbose { get; init; } + public required bool Json { get; init; } + public required SizeFormat SizeFormat { get; init; } + public required int Threads { get; init; } +} + +/// +/// Shared System.CommandLine option definitions for --exe, --verbose, --json, --si, --threads. +/// Create an instance, call to register options on a command, +/// then call inside the action to build a . +/// +public sealed class CommonCommandOptions +{ + // csharpier-ignore-start + public Option Exe { get; } = new("--exe", "-e") + { + Description = "Path to the GTA V installation directory (containing GTA5.exe)", + Required = true, + }; + + public Option Verbose { get; } = new("--verbose", "-v") + { + Description = "Show verbose output", + }; + + public Option Json { get; } = new("--json") + { + Description = "Output results in JSON format for scripting", + }; + + public Option Si { get; } = new("--si") + { + Description = "Use SI units (1000-based: KB, MB) instead of IEC (1024-based: KiB, MiB)", + }; + + public Option Threads { get; } = new("--threads", "-t") + { + Description = "Number of threads for parallel processing", + DefaultValueFactory = _ => Environment.ProcessorCount, + }; + // csharpier-ignore-end + + public void AddTo(Command command) + { + command.Add(Exe); + command.Add(Verbose); + command.Add(Json); + command.Add(Si); + command.Add(Threads); + } + + public CommonOptions Parse(ParseResult parseResult) + { + return new CommonOptions + { + ExePath = parseResult.GetRequiredValue(Exe).FullName, + Verbose = parseResult.GetValue(Verbose), + Json = parseResult.GetValue(Json), + SizeFormat = parseResult.GetValue(Si) ? SizeFormat.SI : SizeFormat.IEC, + Threads = parseResult.GetValue(Threads), + }; + } +} diff --git a/CodeWalker.Cli/DiffHandler.cs b/CodeWalker.Cli/DiffHandler.cs index 25bbfab6f..d22ffde62 100644 --- a/CodeWalker.Cli/DiffHandler.cs +++ b/CodeWalker.Cli/DiffHandler.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.IO; using System.Text.Json; +using System.Threading.Tasks; using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; @@ -12,18 +13,16 @@ public record DiffOptions { public required string LeftPath { get; init; } public required string RightPath { get; init; } - public required string ExePath { get; init; } + public required CommonOptions Common { get; init; } public required bool Gen9 { get; init; } public required bool Recursive { get; init; } - public required bool Verbose { get; init; } - public required bool Json { get; init; } - public required SizeFormat SizeFormat { get; init; } } public static class DiffHandler { public static Command CreateCommand() { + CommonCommandOptions commonOpts = new(); // csharpier-ignore-start Option leftOption = new("--left", "-l") { @@ -37,12 +36,6 @@ public static Command CreateCommand() Required = true, }; - Option exeOption = new("--exe", "-e") - { - Description = "Path to the GTA V installation directory (containing GTA5.exe)", - Required = true, - }; - Option gen9Option = new("--gen9", "-g") { Description = "Use GTA V Enhanced (Gen9) mode", @@ -52,34 +45,16 @@ public static Command CreateCommand() { Description = "Include nested RPFs in comparison", }; - - Option verboseOption = new("--verbose", "-v") - { - Description = "Show unchanged files too", - }; - - Option jsonOption = new("--json") - { - Description = "Output results in JSON format", - }; - - Option siOption = new("--si") - { - Description = "Use SI units (1000-based: KB, MB) instead of IEC (1024-based: KiB, MiB)", - }; // csharpier-ignore-end Command command = new("diff", "Compare two RPF archives") { leftOption, rightOption, - exeOption, gen9Option, recursiveOption, - verboseOption, - jsonOption, - siOption, }; + commonOpts.AddTo(command); command.Aliases.Add("d"); command.SetAction(parseResult => @@ -88,12 +63,9 @@ public static Command CreateCommand() { LeftPath = parseResult.GetRequiredValue(leftOption).FullName, RightPath = parseResult.GetRequiredValue(rightOption).FullName, - ExePath = parseResult.GetRequiredValue(exeOption).FullName, + Common = commonOpts.Parse(parseResult), Gen9 = parseResult.GetValue(gen9Option), Recursive = parseResult.GetValue(recursiveOption), - Verbose = parseResult.GetValue(verboseOption), - Json = parseResult.GetValue(jsonOption), - SizeFormat = parseResult.GetValue(siOption) ? SizeFormat.SI : SizeFormat.IEC, }; return Execute(options); }); @@ -103,65 +75,65 @@ public static Command CreateCommand() public static int Execute(DiffOptions options) { - List errorMessages = []; - - Json.DiffResult result = new() - { - Success = false, - LeftRpf = options.LeftPath, - RightRpf = options.RightPath, - Added = [], - Removed = [], - Modified = [], - Unchanged = [], - Summary = new Json.DiffSummary + Json.DiffResult ErrorResult(string[] errorMessages) => + new() { - AddedCount = 0, - RemovedCount = 0, - ModifiedCount = 0, - UnchangedCount = 0, - }, - ErrorMessages = errorMessages, - }; + Success = false, + LeftRpf = options.LeftPath, + RightRpf = options.RightPath, + Added = [], + Removed = [], + Modified = [], + Unchanged = [], + Summary = new Json.DiffSummary + { + AddedCount = 0, + RemovedCount = 0, + ModifiedCount = 0, + UnchangedCount = 0, + }, + ErrorMessages = errorMessages, + }; // Validate both RPF files exist before loading keys string? leftError = RpfService.ValidateInputs( options.LeftPath, - options.ExePath, + options.Common.ExePath, options.Gen9 ); if (leftError != null) { - return RpfService.ReportError(leftError, options.Json, result); + return RpfService.ReportError(leftError, options.Common.Json, ErrorResult([])); } - string? rightError = RpfService.ValidateInputs( - options.RightPath, - options.ExePath, - options.Gen9 - ); - if (rightError != null) + if (!File.Exists(options.RightPath)) { - return RpfService.ReportError(rightError, options.Json, result); + return RpfService.ReportError( + $"RPF file not found: {options.RightPath}", + options.Common.Json, + ErrorResult([]) + ); } try { - if (!options.Json) + if (!options.Common.Json) Console.Error.WriteLine("Loading encryption keys..."); - RpfService.LoadKeys(options.ExePath, options.Gen9); + RpfService.LoadKeys(options.Common.ExePath, options.Gen9); + + List errorMessages = []; RpfFile leftRpf = RpfService.OpenRpf( options.LeftPath, - options.Verbose, - options.Json, + options.Common.Verbose, + options.Common.Json, errorMessages ); RpfFile rightRpf = RpfService.OpenRpf( options.RightPath, - options.Verbose, - options.Json, + options.Common.Verbose, + options.Common.Json, errorMessages ); @@ -190,69 +162,86 @@ public static int Execute(DiffOptions options) rightDict[entry.Path] = (rpf, entry); } - List added = []; - List removed = []; - List modified = []; - List unchanged = []; - - SizeFormat sizeFormat = options.SizeFormat; + SizeFormat sizeFormat = options.Common.SizeFormat; - // Find removed and modified/unchanged - foreach (KeyValuePair kvp in leftDict) + // Find removed and modified/unchanged — entries in left that also appear in right + // need byte comparison, so parallelize this + string[] commonPaths; { - string path = kvp.Key; - (RpfFile leftRpfRef, RpfFileEntry leftEntry) = kvp.Value; + List paths = []; + foreach (string path in leftDict.Keys) + { + if (rightDict.ContainsKey(path)) + paths.Add(path); + } + commonPaths = paths.ToArray(); + } + + // Result per common path: null = unchanged, non-null = modified entry + bool[] isModifiedArr = new bool[commonPaths.Length]; - if (rightDict.TryGetValue(path, out (RpfFile rpf, RpfFileEntry entry) right)) + Parallel.For( + 0, + commonPaths.Length, + new ParallelOptions { + MaxDegreeOfParallelism = Math.Max(1, options.Common.Threads), + }, + i => + { + string path = commonPaths[i]; + (RpfFile leftRpfRef, RpfFileEntry leftEntry) = leftDict[path]; + (RpfFile rightRpfRef, RpfFileEntry rightEntry) = rightDict[path]; + long leftSize = leftEntry.GetFileSize(); - long rightSize = right.entry.GetFileSize(); + long rightSize = rightEntry.GetFileSize(); string leftType = RpfService.GetFileType(leftEntry); - string rightType = RpfService.GetFileType(right.entry); + string rightType = RpfService.GetFileType(rightEntry); - bool isModified; if (leftSize != rightSize || leftType != rightType) { - isModified = true; + isModifiedArr[i] = true; } else { byte[]? leftData = leftRpfRef.ExtractFile(leftEntry); - byte[]? rightData = right.rpf.ExtractFile(right.entry); - isModified = !ContentEquals(leftData, rightData); + byte[]? rightData = rightRpfRef.ExtractFile(rightEntry); + isModifiedArr[i] = !ContentEquals(leftData, rightData); } + } + ); - if (isModified) - { - modified.Add( - new Json.DiffEntry - { - Path = path, - Name = leftEntry.Name, - Type = leftType, - LeftSize = leftSize, - RightSize = rightSize, - } - ); - } - else - { - unchanged.Add( - new Json.DiffEntry - { - Path = path, - Name = leftEntry.Name, - Type = leftType, - Size = leftSize, - SizeFormatted = sizeFormat.ToFormattedString(leftSize), - } - ); - } + List added = []; + List removed = []; + List modified = []; + List unchanged = []; + + // Aggregate common path results + for (int i = 0; i < commonPaths.Length; i++) + { + string path = commonPaths[i]; + (_, RpfFileEntry leftEntry) = leftDict[path]; + (_, RpfFileEntry rightEntry) = rightDict[path]; + + if (isModifiedArr[i]) + { + long leftSize = leftEntry.GetFileSize(); + long rightSize = rightEntry.GetFileSize(); + modified.Add( + new Json.DiffEntry + { + Path = path, + Name = leftEntry.Name, + Type = RpfService.GetFileType(leftEntry), + LeftSize = leftSize, + RightSize = rightSize, + } + ); } else { long size = leftEntry.GetFileSize(); - removed.Add( + unchanged.Add( new Json.DiffEntry { Path = path, @@ -265,7 +254,26 @@ public static int Execute(DiffOptions options) } } - // Find added + // Find removed (left only) + foreach (KeyValuePair kvp in leftDict) + { + if (!rightDict.ContainsKey(kvp.Key)) + { + long size = kvp.Value.entry.GetFileSize(); + removed.Add( + new Json.DiffEntry + { + Path = kvp.Key, + Name = kvp.Value.entry.Name, + Type = RpfService.GetFileType(kvp.Value.entry), + Size = size, + SizeFormatted = sizeFormat.ToFormattedString(size), + } + ); + } + } + + // Find added (right only) foreach (KeyValuePair kvp in rightDict) { if (!leftDict.ContainsKey(kvp.Key)) @@ -298,17 +306,20 @@ public static int Execute(DiffOptions options) UnchangedCount = unchanged.Count, }; - result = result with + Json.DiffResult result = new() { Success = true, - Added = added, - Removed = removed, - Modified = modified, - Unchanged = unchanged, + LeftRpf = options.LeftPath, + RightRpf = options.RightPath, + Added = added.ToArray(), + Removed = removed.ToArray(), + Modified = modified.ToArray(), + Unchanged = unchanged.ToArray(), Summary = summary, + ErrorMessages = errorMessages.ToArray(), }; - if (options.Json) + if (options.Common.Json) { Console.WriteLine( JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) @@ -348,7 +359,7 @@ public static int Execute(DiffOptions options) Console.WriteLine(); } - if (options.Verbose && unchanged.Count > 0) + if (options.Common.Verbose && unchanged.Count > 0) { Console.WriteLine($"Unchanged ({unchanged.Count}):"); foreach (Json.DiffEntry entry in unchanged) @@ -369,9 +380,9 @@ public static int Execute(DiffOptions options) { return RpfService.ReportError( ex.Message, - options.Json, - result, - options.Verbose ? ex.StackTrace : null + options.Common.Json, + ErrorResult([]), + options.Common.Verbose ? ex.StackTrace : null ); } } diff --git a/CodeWalker.Cli/ExportAudioHandler.cs b/CodeWalker.Cli/ExportAudioHandler.cs new file mode 100644 index 000000000..e5f4c1e96 --- /dev/null +++ b/CodeWalker.Cli/ExportAudioHandler.cs @@ -0,0 +1,94 @@ +using System.CommandLine; +using System.IO; +using CodeWalker.GameFiles; + +namespace CodeWalker.Cli; + +public static class ExportAudioHandler +{ + private static readonly string[] DefaultFilters = ["*.awc"]; + + public static Command CreateCommand() + { + ExportCommandOptions exportOpts = new(); + + Command command = new("audio", "Export .awc audio containers to WAV/MIDI files"); + exportOpts.AddTo(command); + command.Aliases.Add("a"); + command.Aliases.Add("awc"); + + command.SetAction(parseResult => + { + ExportOptions options = exportOpts.Parse(parseResult); + if (options.Rpf.Filters.Length == 0) + { + options = options with { Rpf = options.Rpf with { Filters = DefaultFilters } }; + } + return ExportService.Execute(options, "wav", "Audio", ProcessFile); + }); + + return command; + } + + private static (Json.ExportFileEntry? entry, string? error) ProcessFile( + RpfFileEntry fileEntry, + byte[] data, + string fileOutputDir + ) + { + AwcFile awc = RpfFile.GetFile(fileEntry, data); + if (awc?.Streams == null || awc.Streams.Length == 0) + { + return ( + new Json.ExportFileEntry + { + Path = fileEntry.Path, + Name = fileEntry.Name, + OutputFiles = 0, + Status = "skipped", + }, + null + ); + } + + if (!Directory.Exists(fileOutputDir)) + { + Directory.CreateDirectory(fileOutputDir); + } + + int streamCount = 0; + foreach (AwcStream stream in awc.Streams) + { + if (stream.Hash == 0) + continue; + + string streamName = stream.Name; + + if (stream.MidiChunk?.Data != null) + { + string midiPath = Path.Combine(fileOutputDir, streamName + ".midi"); + File.WriteAllBytes(midiPath, stream.MidiChunk.Data); + streamCount++; + } + else + { + byte[] wav = stream.GetWavFile(); + string wavPath = Path.Combine(fileOutputDir, streamName + ".wav"); + File.WriteAllBytes(wavPath, wav); + streamCount++; + } + } + + return ( + new Json.ExportFileEntry + { + Path = fileEntry.Path, + Name = fileEntry.Name, + OutputPath = fileOutputDir, + OutputFiles = streamCount, + Status = "exported", + }, + null + ); + } +} diff --git a/CodeWalker.Cli/ExportHandler.cs b/CodeWalker.Cli/ExportHandler.cs new file mode 100644 index 000000000..7917616bc --- /dev/null +++ b/CodeWalker.Cli/ExportHandler.cs @@ -0,0 +1,75 @@ +using System; +using System.CommandLine; +using System.IO; + +namespace CodeWalker.Cli; + +public record ExportOptions +{ + public required RpfOptions Rpf { get; init; } + public required string OutputPath { get; init; } + public required bool DryRun { get; init; } + public required bool Progress { get; init; } +} + +public sealed class ExportCommandOptions +{ + private readonly RpfCommandOptions _rpfOpts = new(); + + // csharpier-ignore-start + public Option Output { get; } = new("--output", "-o") + { + Description = "Output directory", + DefaultValueFactory = _ => new DirectoryInfo(Directory.GetCurrentDirectory()), + }; + + public Option DryRun { get; } = new("--dry-run", "-n") + { + Description = "Show what would be exported without writing files", + }; + + public Option Progress { get; } = new("--progress", "-P") + { + Description = "Show progress bar during export", + }; + // csharpier-ignore-end + + public void AddTo(Command command) + { + _rpfOpts.AddTo(command); + command.Add(Output); + command.Add(DryRun); + command.Add(Progress); + } + + public ExportOptions Parse(ParseResult parseResult) + { + return new ExportOptions + { + Rpf = _rpfOpts.Parse(parseResult), + OutputPath = parseResult.GetValue(Output)?.FullName ?? Directory.GetCurrentDirectory(), + DryRun = parseResult.GetValue(DryRun), + Progress = parseResult.GetValue(Progress), + }; + } +} + +public static class ExportHandler +{ + public static Command CreateCommand() + { + Command command = new( + "export", + "Export game files to external formats (XML, DDS, WAV, text)" + ) + { + ExportXmlHandler.CreateCommand(), + ExportTexturesHandler.CreateCommand(), + ExportAudioHandler.CreateCommand(), + ExportTextHandler.CreateCommand(), + }; + command.Aliases.Add("e"); + + return command; + } +} diff --git a/CodeWalker.Cli/ExportService.cs b/CodeWalker.Cli/ExportService.cs new file mode 100644 index 000000000..b48528b3a --- /dev/null +++ b/CodeWalker.Cli/ExportService.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; + +namespace CodeWalker.Cli; + +/// +/// Delegate for processing a single file entry during export. +/// Returns an on success (status = "exported", "unsupported", "skipped"), +/// or a tuple with a null entry and error string on failure. +/// +/// The RPF file entry to process. +/// The raw file data extracted from the RPF. +/// The output directory for this file (includes relative path). +public delegate (Json.ExportFileEntry? entry, string? error) ExportFileProcessor( + RpfFileEntry fileEntry, + byte[] data, + string fileOutputDir +); + +public static class ExportService +{ + public static int Execute( + ExportOptions options, + string format, + string summaryLabel, + ExportFileProcessor processor + ) + { + Json.ExportResult ErrorResult(string[] errorMessages) => + new() + { + Success = false, + RpfFile = options.Rpf.RpfPath, + OutputDir = options.OutputPath, + Format = format, + TotalFiles = 0, + Exported = 0, + Skipped = 0, + Errors = 0, + DryRun = options.DryRun, + Files = [], + ErrorMessages = errorMessages, + }; + + string? initError = RpfService.ValidateAndLoadKeys( + options.Rpf.RpfPath, + options.Rpf.ExePath, + options.Rpf.Gen9, + options.Rpf.Json + ); + if (initError != null) + { + return RpfService.ReportError(initError, options.Rpf.Json, ErrorResult([])); + } + + try + { + List scanErrors = []; + RpfFile rpf = RpfService.OpenRpf( + options.Rpf.RpfPath, + options.Rpf.Verbose, + options.Rpf.Json, + scanErrors + ); + + if (!options.Rpf.Json && options.DryRun) + { + Console.Error.WriteLine("Dry run mode - no files will be exported"); + } + + string outputDir = options.OutputPath; + + if (!options.DryRun && !Directory.Exists(outputDir)) + { + Directory.CreateDirectory(outputDir); + } + + List<(RpfFile rpf, RpfFileEntry entry)> filesToExport = RpfService.CollectFiles( + rpf, + options.Rpf.Filters, + options.Rpf.Recursive + ); + + int totalNonRpfFiles = RpfService.CountNonRpfFiles(rpf, options.Rpf.Recursive); + int filterSkipped = totalNonRpfFiles - filesToExport.Count; + + (bool success, Json.ExportFileEntry? jsonEntry, string? errorMessage)[] results = new ( + bool, + Json.ExportFileEntry?, + string? + )[filesToExport.Count]; + + object consoleLock = new(); + + using ( + ProgressBar progress = new( + filesToExport.Count, + options.Progress && !options.Rpf.Json + ) + ) + { + Parallel.For( + 0, + filesToExport.Count, + new ParallelOptions + { + MaxDegreeOfParallelism = Math.Max(1, options.Rpf.Threads), + }, + i => + { + (RpfFile sourceRpf, RpfFileEntry fileEntry) = filesToExport[i]; + try + { + string relativePath = + Path.GetDirectoryName(fileEntry.Path) + ?.Replace('\\', Path.DirectorySeparatorChar) + ?? ""; + + string fileOutputDir = Path.Combine(outputDir, relativePath); + + if (options.DryRun) + { + if (options.Rpf.Verbose && !options.Rpf.Json) + { + lock (consoleLock) + { + Console.WriteLine($"Would export: {fileEntry.Path}"); + } + } + results[i] = ( + true, + new Json.ExportFileEntry + { + Path = fileEntry.Path, + Name = fileEntry.Name, + OutputFiles = 0, + Status = "dry_run", + }, + null + ); + progress.Increment(fileEntry.Path); + return; + } + + byte[]? data = sourceRpf.ExtractFile(fileEntry); + if (data == null) + { + results[i] = (false, null, $"Failed to extract: {fileEntry.Path}"); + progress.Increment(); + return; + } + + (Json.ExportFileEntry? entry, string? error) = processor( + fileEntry, + data, + fileOutputDir + ); + + if (error != null) + { + results[i] = (false, entry, error); + } + else if (entry != null) + { + if ( + options.Rpf.Verbose + && !options.Rpf.Json + && !options.Progress + && entry.Status == "exported" + ) + { + lock (consoleLock) + { + Console.Error.WriteLine( + $"Exported: {fileEntry.Path} -> {entry.OutputFiles} file(s)" + ); + } + } + results[i] = (true, entry, null); + } + else + { + results[i] = (false, null, $"No result for: {fileEntry.Path}"); + } + + progress.Increment(fileEntry.Path); + } + catch (Exception ex) + { + if (!options.Rpf.Json) + { + lock (consoleLock) + { + Console.Error.WriteLine( + $"Error exporting {fileEntry.Path}: {ex.Message}" + ); + } + } + results[i] = ( + false, + null, + $"Error exporting {fileEntry.Path}: {ex.Message}" + ); + progress.Increment(); + } + } + ); + } + + int exported = 0; + int skipped = 0; + int errors = 0; + List files = []; + List errorMessages = new(scanErrors); + + foreach (var (success, jsonEntry, errorMessage) in results) + { + if (success && jsonEntry?.Status == "exported") + exported++; + + if (jsonEntry?.Status is "unsupported" or "skipped") + skipped++; + + if (jsonEntry != null) + files.Add(jsonEntry); + + if (!success && errorMessage != null) + { + errors++; + errorMessages.Add(errorMessage); + } + } + + skipped += filterSkipped; + + Json.ExportResult result = new() + { + Success = errors == 0, + RpfFile = options.Rpf.RpfPath, + OutputDir = options.OutputPath, + Format = format, + TotalFiles = filesToExport.Count, + Exported = exported, + Skipped = skipped, + Errors = errors, + DryRun = options.DryRun, + Files = files.ToArray(), + ErrorMessages = errorMessages.ToArray(), + }; + + if (options.Rpf.Json) + { + Console.WriteLine( + JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + ); + } + else + { + Console.Error.WriteLine(); + string action = options.DryRun ? "would be exported" : "exported"; + Console.Error.WriteLine( + $"{summaryLabel} export complete: {exported} files {action}, {skipped} skipped, {errors} errors" + ); + } + + return errors > 0 ? 1 : 0; + } + catch (Exception ex) + { + return RpfService.ReportError( + ex.Message, + options.Rpf.Json, + ErrorResult([]), + options.Rpf.Verbose ? ex.StackTrace : null + ); + } + } +} diff --git a/CodeWalker.Cli/ExportTextHandler.cs b/CodeWalker.Cli/ExportTextHandler.cs new file mode 100644 index 000000000..4de6205e8 --- /dev/null +++ b/CodeWalker.Cli/ExportTextHandler.cs @@ -0,0 +1,79 @@ +using System.CommandLine; +using System.IO; +using System.Text; +using CodeWalker.GameFiles; + +namespace CodeWalker.Cli; + +public static class ExportTextHandler +{ + private static readonly string[] DefaultFilters = ["*.gxt2"]; + + public static Command CreateCommand() + { + ExportCommandOptions exportOpts = new(); + + Command command = new("text", "Export .gxt2 localization files to plain text"); + exportOpts.AddTo(command); + command.Aliases.Add("g"); + command.Aliases.Add("gxt2"); + + command.SetAction(parseResult => + { + ExportOptions options = exportOpts.Parse(parseResult); + if (options.Rpf.Filters.Length == 0) + { + options = options with { Rpf = options.Rpf with { Filters = DefaultFilters } }; + } + return ExportService.Execute(options, "txt", "Text", ProcessFile); + }); + + return command; + } + + private static (Json.ExportFileEntry? entry, string? error) ProcessFile( + RpfFileEntry fileEntry, + byte[] data, + string fileOutputDir + ) + { + Gxt2File gxt = RpfFile.GetFile(fileEntry, data); + string text = gxt.ToText(); + + if (string.IsNullOrEmpty(text)) + { + return ( + new Json.ExportFileEntry + { + Path = fileEntry.Path, + Name = fileEntry.Name, + OutputFiles = 0, + Status = "skipped", + }, + null + ); + } + + if (!Directory.Exists(fileOutputDir)) + { + Directory.CreateDirectory(fileOutputDir); + } + + string outputFileName = Path.GetFileNameWithoutExtension(fileEntry.Name) + ".txt"; + string outputPath = Path.Combine(fileOutputDir, outputFileName); + + File.WriteAllText(outputPath, text, Encoding.UTF8); + + return ( + new Json.ExportFileEntry + { + Path = fileEntry.Path, + Name = fileEntry.Name, + OutputPath = outputPath, + OutputFiles = 1, + Status = "exported", + }, + null + ); + } +} diff --git a/CodeWalker.Cli/ExportTexturesHandler.cs b/CodeWalker.Cli/ExportTexturesHandler.cs new file mode 100644 index 000000000..ffc11eb6f --- /dev/null +++ b/CodeWalker.Cli/ExportTexturesHandler.cs @@ -0,0 +1,86 @@ +using System.CommandLine; +using System.IO; +using CodeWalker.GameFiles; +using CodeWalker.Utils; + +namespace CodeWalker.Cli; + +public static class ExportTexturesHandler +{ + private static readonly string[] DefaultFilters = ["*.ytd"]; + + public static Command CreateCommand() + { + ExportCommandOptions exportOpts = new(); + + Command command = new("textures", "Export .ytd texture dictionaries to DDS files"); + exportOpts.AddTo(command); + command.Aliases.Add("t"); + command.Aliases.Add("ytd"); + + command.SetAction(parseResult => + { + ExportOptions options = exportOpts.Parse(parseResult); + if (options.Rpf.Filters.Length == 0) + { + options = options with { Rpf = options.Rpf with { Filters = DefaultFilters } }; + } + return ExportService.Execute(options, "dds", "Texture", ProcessFile); + }); + + return command; + } + + private static (Json.ExportFileEntry? entry, string? error) ProcessFile( + RpfFileEntry fileEntry, + byte[] data, + string fileOutputDir + ) + { + YtdFile ytd = RpfFile.GetFile(fileEntry, data); + if ( + ytd?.TextureDict?.Textures?.data_items == null + || ytd.TextureDict.Textures.data_items.Length == 0 + ) + { + return ( + new Json.ExportFileEntry + { + Path = fileEntry.Path, + Name = fileEntry.Name, + OutputFiles = 0, + Status = "skipped", + }, + null + ); + } + + if (!Directory.Exists(fileOutputDir)) + { + Directory.CreateDirectory(fileOutputDir); + } + + int texCount = 0; + foreach (Texture tex in ytd.TextureDict.Textures.data_items) + { + string texName = (tex.Name ?? "unknown") + ".dds"; + string outputPath = Path.Combine(fileOutputDir, texName); + + byte[] dds = DDSIO.GetDDSFile(tex); + File.WriteAllBytes(outputPath, dds); + texCount++; + } + + return ( + new Json.ExportFileEntry + { + Path = fileEntry.Path, + Name = fileEntry.Name, + OutputPath = fileOutputDir, + OutputFiles = texCount, + Status = "exported", + }, + null + ); + } +} diff --git a/CodeWalker.Cli/ExportXmlHandler.cs b/CodeWalker.Cli/ExportXmlHandler.cs new file mode 100644 index 000000000..0035f8f95 --- /dev/null +++ b/CodeWalker.Cli/ExportXmlHandler.cs @@ -0,0 +1,69 @@ +using System.CommandLine; +using System.IO; +using System.Text; +using CodeWalker.GameFiles; + +namespace CodeWalker.Cli; + +public static class ExportXmlHandler +{ + public static Command CreateCommand() + { + ExportCommandOptions exportOpts = new(); + + Command command = new("xml", "Export binary game files to XML"); + exportOpts.AddTo(command); + command.Aliases.Add("x"); + + command.SetAction(parseResult => + { + ExportOptions options = exportOpts.Parse(parseResult); + return ExportService.Execute(options, "xml", "XML", ProcessFile); + }); + + return command; + } + + private static (Json.ExportFileEntry? entry, string? error) ProcessFile( + RpfFileEntry fileEntry, + byte[] data, + string fileOutputDir + ) + { + string xml = MetaXml.GetXml(fileEntry, data, out string filename, fileOutputDir); + + if (string.IsNullOrEmpty(xml)) + { + return ( + new Json.ExportFileEntry + { + Path = fileEntry.Path, + Name = fileEntry.Name, + OutputFiles = 0, + Status = "unsupported", + }, + null + ); + } + + if (!string.IsNullOrEmpty(fileOutputDir) && !Directory.Exists(fileOutputDir)) + { + Directory.CreateDirectory(fileOutputDir); + } + + string outputPath = Path.Combine(fileOutputDir, filename); + File.WriteAllText(outputPath, xml, Encoding.UTF8); + + return ( + new Json.ExportFileEntry + { + Path = fileEntry.Path, + Name = fileEntry.Name, + OutputPath = outputPath, + OutputFiles = 1, + Status = "exported", + }, + null + ); + } +} diff --git a/CodeWalker.Cli/ExtractHandler.cs b/CodeWalker.Cli/ExtractHandler.cs index 93d0f262c..2cfd83679 100644 --- a/CodeWalker.Cli/ExtractHandler.cs +++ b/CodeWalker.Cli/ExtractHandler.cs @@ -75,22 +75,20 @@ public static Command CreateCommand() public static int Execute(ExtractOptions options) { - List files = []; - List errorMessages = []; - - Json.ExtractResult result = new() - { - Success = false, - RpfFile = options.Rpf.RpfPath, - OutputDir = options.OutputPath ?? Directory.GetCurrentDirectory(), - TotalFiles = 0, - Extracted = 0, - Skipped = 0, - Errors = 0, - DryRun = options.DryRun, - Files = files, - ErrorMessages = errorMessages, - }; + Json.ExtractResult ErrorResult(string[] errorMessages) => + new() + { + Success = false, + RpfFile = options.Rpf.RpfPath, + OutputDir = options.OutputPath ?? Directory.GetCurrentDirectory(), + TotalFiles = 0, + Extracted = 0, + Skipped = 0, + Errors = 0, + DryRun = options.DryRun, + Files = [], + ErrorMessages = errorMessages, + }; string? initError = RpfService.ValidateAndLoadKeys( options.Rpf.RpfPath, @@ -100,19 +98,20 @@ public static int Execute(ExtractOptions options) ); if (initError != null) { - return RpfService.ReportError(initError, options.Rpf.Json, result); + return RpfService.ReportError(initError, options.Rpf.Json, ErrorResult([])); } try { + List scanErrors = []; RpfFile rpf = RpfService.OpenRpf( options.Rpf.RpfPath, options.Rpf.Verbose, options.Rpf.Json, - errorMessages + scanErrors ); - result = result with { TotalFiles = rpf.GrandTotalFileCount }; + uint grandTotalFileCount = rpf.GrandTotalFileCount; if (!options.Rpf.Json && options.DryRun) { @@ -285,6 +284,9 @@ jsonEntry with // Aggregate results in order int extracted = 0; int errors = 0; + List files = []; + List errorMessages = new(scanErrors); + foreach (var (success, jsonEntry, errorMessage) in results) { if (success) @@ -302,12 +304,18 @@ jsonEntry with skipped += overwriteSkipped; - result = result with + Json.ExtractResult result = new() { + Success = errors == 0, + RpfFile = options.Rpf.RpfPath, + OutputDir = options.OutputPath ?? Directory.GetCurrentDirectory(), + TotalFiles = grandTotalFileCount, Extracted = extracted, Skipped = skipped, Errors = errors, - Success = errors == 0, + DryRun = options.DryRun, + Files = files.ToArray(), + ErrorMessages = errorMessages.ToArray(), }; if (options.Rpf.Json) @@ -332,7 +340,7 @@ jsonEntry with return RpfService.ReportError( ex.Message, options.Rpf.Json, - result, + ErrorResult([]), options.Rpf.Verbose ? ex.StackTrace : null ); } diff --git a/CodeWalker.Cli/Gen9Handler.cs b/CodeWalker.Cli/Gen9Handler.cs index 4395aa571..d2674b192 100644 --- a/CodeWalker.Cli/Gen9Handler.cs +++ b/CodeWalker.Cli/Gen9Handler.cs @@ -3,6 +3,8 @@ using System.CommandLine; using System.IO; using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; using CodeWalker.Cli.Helpers; using CodeWalker.Core.Utils; using CodeWalker.GameFiles; @@ -13,19 +15,18 @@ public record Gen9Options { public required string InputPath { get; init; } public required string OutputPath { get; init; } - public required string ExePath { get; init; } + public required CommonOptions Common { get; init; } public required bool NoRecurse { get; init; } public required bool NoOverwrite { get; init; } public required bool SkipUnconverted { get; init; } public required bool Progress { get; init; } - public required bool Verbose { get; init; } - public required bool Json { get; init; } } public static class Gen9Handler { public static Command CreateCommand() { + CommonCommandOptions commonOpts = new(); // csharpier-ignore-start Option inputOption = new("--input", "-i") { @@ -39,12 +40,6 @@ public static Command CreateCommand() Required = true, }; - Option exeOption = new("--exe", "-e") - { - Description = "Path to the GTA V installation directory (containing GTA5.exe)", - Required = true, - }; - Option noRecurseOption = new("--no-recurse") { Description = "Skip subfolders (default: recurse)", @@ -64,30 +59,18 @@ public static Command CreateCommand() { Description = "Show progress bar", }; - - Option verboseOption = new("--verbose", "-v") - { - Description = "Show per-file status", - }; - - Option jsonOption = new("--json") - { - Description = "Output results in JSON format", - }; // csharpier-ignore-end Command command = new("gen9", "Convert files between standard and enhanced (Gen9) formats") { inputOption, outputOption, - exeOption, noRecurseOption, noOverwriteOption, skipUnconvertedOption, progressOption, - verboseOption, - jsonOption, }; + commonOpts.AddTo(command); command.Aliases.Add("g"); command.SetAction(parseResult => @@ -96,13 +79,11 @@ public static Command CreateCommand() { InputPath = parseResult.GetRequiredValue(inputOption).FullName, OutputPath = parseResult.GetRequiredValue(outputOption).FullName, - ExePath = parseResult.GetRequiredValue(exeOption).FullName, + Common = commonOpts.Parse(parseResult), NoRecurse = parseResult.GetValue(noRecurseOption), NoOverwrite = parseResult.GetValue(noOverwriteOption), SkipUnconverted = parseResult.GetValue(skipUnconvertedOption), Progress = parseResult.GetValue(progressOption), - Verbose = parseResult.GetValue(verboseOption), - Json = parseResult.GetValue(jsonOption), }; return Execute(options); }); @@ -112,29 +93,27 @@ public static Command CreateCommand() public static int Execute(Gen9Options options) { - List files = []; - List errorMessages = []; - - Json.Gen9Result result = new() - { - Success = false, - InputFolder = options.InputPath, - OutputFolder = options.OutputPath, - TotalFiles = 0, - Converted = 0, - Skipped = 0, - Copied = 0, - Errors = 0, - Files = files, - ErrorMessages = errorMessages, - }; + Json.Gen9Result ErrorResult(string[] errorMessages) => + new() + { + Success = false, + InputFolder = options.InputPath, + OutputFolder = options.OutputPath, + TotalFiles = 0, + Converted = 0, + Skipped = 0, + Copied = 0, + Errors = 0, + Files = [], + ErrorMessages = errorMessages, + }; if (!Directory.Exists(options.InputPath)) { return RpfService.ReportError( $"Input folder not found: {options.InputPath}", - options.Json, - result + options.Common.Json, + ErrorResult([]) ); } @@ -148,29 +127,23 @@ public static int Execute(Gen9Options options) { return RpfService.ReportError( "Input folder and Output folder must be different.", - options.Json, - result + options.Common.Json, + ErrorResult([]) ); } - string exeFile = "GTA5_Enhanced.exe"; - if (!File.Exists(Path.Combine(options.ExePath, exeFile))) + string? exeError = RpfService.ValidateExeAndLoadKeys( + options.Common.ExePath, + true, + options.Common.Json + ); + if (exeError != null) { - return RpfService.ReportError( - $"{exeFile} not found in: {options.ExePath}", - options.Json, - result - ); + return RpfService.ReportError(exeError, options.Common.Json, ErrorResult([])); } try { - if (!options.Json) - { - Console.Error.WriteLine("Loading encryption keys..."); - } - GTA5Keys.LoadFromPath(options.ExePath, true); - bool previousGen9 = RpfManager.IsGen9; RpfManager.IsGen9 = true; @@ -193,21 +166,28 @@ public static int Execute(Gen9Options options) string[] allPaths = Directory.GetFileSystemEntries(inputFolder, "*", searchOption); - // Filter to files only List filePaths = []; + List rpfPaths = []; foreach (string p in allPaths) { - if (File.Exists(p)) + if (!File.Exists(p)) + continue; + + if (Path.GetExtension(p).Equals(".rpf", StringComparison.OrdinalIgnoreCase)) + { + rpfPaths.Add(p); + } + else { filePaths.Add(p); } } - if (!options.Json) + int totalFileCount = filePaths.Count + rpfPaths.Count; + + if (!options.Common.Json) { - Console.Error.WriteLine( - $"Found {filePaths.Count} files in {options.InputPath}" - ); + Console.Error.WriteLine($"Found {totalFileCount} files in {options.InputPath}"); } int converted = 0; @@ -215,68 +195,69 @@ public static int Execute(Gen9Options options) int copied = 0; int errors = 0; bool copyUnconverted = !options.SkipUnconverted; + List files = []; + List errorMessages = []; using ( - ProgressBar progress = new(filePaths.Count, options.Progress && !options.Json) + ProgressBar progress = new( + totalFileCount, + options.Progress && !options.Common.Json + ) ) { - foreach (string path in filePaths) - { - string relPath = path.Substring(inputFolder.Length); - string outPath = Path.Combine(options.OutputPath, relPath); - - try + (Json.Gen9FileEntry entry, string? error)[] nonRpfResults = new ( + Json.Gen9FileEntry, + string? + )[filePaths.Count]; + + Parallel.For( + 0, + filePaths.Count, + new ParallelOptions { - if (options.NoOverwrite && File.Exists(outPath)) + MaxDegreeOfParallelism = Math.Max(1, options.Common.Threads), + }, + i => + { + string path = filePaths[i]; + string relPath = path.Substring(inputFolder.Length); + string outPath = Path.Combine(options.OutputPath, relPath); + + try { - skipped++; - files.Add( - new Json.Gen9FileEntry + if (options.NoOverwrite && File.Exists(outPath)) + { + nonRpfResults[i] = ( + new Json.Gen9FileEntry + { + Path = relPath, + Status = "skipped", + Message = "Output file already exists", + }, + null + ); + if (options.Common.Verbose && !options.Common.Json) { - Path = relPath, - Status = "skipped", - Message = "Output file already exists", + Console.Error.WriteLine($"{relPath} - skipped (exists)"); } - ); - if (options.Verbose && !options.Json) - { - Console.Error.WriteLine($"{relPath} - skipped (exists)"); + progress.Increment(relPath); + return; } - progress.Increment(relPath); - continue; - } - string? outDir = Path.GetDirectoryName(outPath); - if (!string.IsNullOrEmpty(outDir) && !Directory.Exists(outDir)) - { - Directory.CreateDirectory(outDir); - } - - string ext = Path.GetExtension(path).ToLowerInvariant(); + string? outDir = Path.GetDirectoryName(outPath); + if (!string.IsNullOrEmpty(outDir) && !Directory.Exists(outDir)) + { + Directory.CreateDirectory(outDir); + } - if (ext == ".rpf") - { - ProcessRpfFile( - path, - outPath, - relPath, - options, - files, - errorMessages, - ref converted, - ref skipped, - ref errors - ); - } - else - { + string ext = Path.GetExtension(path).ToLowerInvariant(); byte[] dataIn = File.ReadAllBytes(path); byte[] dataOut = Gen9Converter.TryConvert( dataIn, ext, msg => { - if (options.Verbose && !options.Json) + if (options.Common.Verbose && !options.Common.Json) Console.Error.WriteLine(msg); }, relPath, @@ -287,36 +268,130 @@ out bool wasConverted if (wasConverted) { File.WriteAllBytes(outPath, dataOut); - converted++; - files.Add( + nonRpfResults[i] = ( new Json.Gen9FileEntry { Path = relPath, Status = "converted", - } + }, + null ); } else if (dataOut != null) { File.WriteAllBytes(outPath, dataOut); - copied++; - files.Add( - new Json.Gen9FileEntry { Path = relPath, Status = "copied" } + nonRpfResults[i] = ( + new Json.Gen9FileEntry + { + Path = relPath, + Status = "copied", + }, + null ); } else { - skipped++; - files.Add( + nonRpfResults[i] = ( new Json.Gen9FileEntry { Path = relPath, Status = "skipped", - } + }, + null ); } + + progress.Increment(relPath); + } + catch (Exception ex) + { + string errorMsg = $"Error processing {relPath}: {ex.Message}"; + nonRpfResults[i] = ( + new Json.Gen9FileEntry + { + Path = relPath, + Status = "error", + Message = ex.Message, + }, + errorMsg + ); + if (!options.Common.Json) + { + Console.Error.WriteLine($"Error: {errorMsg}"); + } + progress.Increment(); + } + } + ); + + // Aggregate non-RPF results + foreach (var (entry, error) in nonRpfResults) + { + files.Add(entry); + switch (entry.Status) + { + case "converted": + converted++; + break; + case "copied": + copied++; + break; + case "skipped": + skipped++; + break; + case "error": + errors++; + if (error != null) + errorMessages.Add(error); + break; + } + } + + // Process RPF files sequentially (unsafe to parallelize) + foreach (string path in rpfPaths) + { + string relPath = path.Substring(inputFolder.Length); + string outPath = Path.Combine(options.OutputPath, relPath); + + try + { + if (options.NoOverwrite && File.Exists(outPath)) + { + skipped++; + files.Add( + new Json.Gen9FileEntry + { + Path = relPath, + Status = "skipped", + Message = "Output file already exists", + } + ); + if (options.Common.Verbose && !options.Common.Json) + { + Console.Error.WriteLine($"{relPath} - skipped (exists)"); + } + progress.Increment(relPath); + continue; } + string? outDir = Path.GetDirectoryName(outPath); + if (!string.IsNullOrEmpty(outDir) && !Directory.Exists(outDir)) + { + Directory.CreateDirectory(outDir); + } + + ProcessRpfFile( + path, + outPath, + relPath, + options, + files, + errorMessages, + ref converted, + ref skipped, + ref errors + ); + progress.Increment(relPath); } catch (Exception ex) @@ -332,7 +407,7 @@ out bool wasConverted Message = ex.Message, } ); - if (!options.Json) + if (!options.Common.Json) { Console.Error.WriteLine($"Error: {errorMsg}"); } @@ -341,17 +416,21 @@ out bool wasConverted } } - result = result with + Json.Gen9Result result = new() { Success = errors == 0, - TotalFiles = filePaths.Count, + InputFolder = options.InputPath, + OutputFolder = options.OutputPath, + TotalFiles = totalFileCount, Converted = converted, Skipped = skipped, Copied = copied, Errors = errors, + Files = files.ToArray(), + ErrorMessages = errorMessages.ToArray(), }; - if (options.Json) + if (options.Common.Json) { Console.WriteLine( JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) @@ -376,9 +455,9 @@ out bool wasConverted { return RpfService.ReportError( ex.Message, - options.Json, - result, - options.Verbose ? ex.StackTrace : null + options.Common.Json, + ErrorResult([]), + options.Common.Verbose ? ex.StackTrace : null ); } } @@ -395,7 +474,7 @@ private static void ProcessRpfFile( ref int errors ) { - if (options.Verbose && !options.Json) + if (options.Common.Verbose && !options.Common.Json) { Console.Error.WriteLine($"{relPath} - Converting RPF contents..."); } @@ -410,12 +489,12 @@ ref int errors rpf.ScanStructure( status => { - if (options.Verbose && !options.Json) + if (options.Common.Verbose && !options.Common.Json) Console.Error.WriteLine(status); }, error => { - if (!options.Json) + if (!options.Common.Json) Console.Error.WriteLine($"Error: {error}"); errorMessages.Add(error); } @@ -474,7 +553,7 @@ ref int errors type, msg => { - if (options.Verbose && !options.Json) + if (options.Common.Verbose && !options.Common.Json) Console.Error.WriteLine(msg); }, rfe.Path, @@ -506,7 +585,7 @@ out bool wasConverted if (changed) { - if (options.Verbose && !options.Json) + if (options.Common.Verbose && !options.Common.Json) { Console.Error.WriteLine($"{currentRpf.Path} - Defragmenting"); } diff --git a/CodeWalker.Cli/HashHandler.cs b/CodeWalker.Cli/HashHandler.cs index e092b4650..b9c6d1cb9 100644 --- a/CodeWalker.Cli/HashHandler.cs +++ b/CodeWalker.Cli/HashHandler.cs @@ -63,16 +63,13 @@ public static Command CreateCommand() public static int Execute(HashOptions options) { - List hashes = []; - - List errorMessages = []; - - Json.HashResult result = new() - { - Success = false, - Hashes = hashes, - ErrorMessages = errorMessages, - }; + Json.HashResult ErrorResult(string[] errorMessages) => + new() + { + Success = false, + Hashes = [], + ErrorMessages = errorMessages, + }; // Validate encoding JenkHashInputEncoding encoding; @@ -91,12 +88,14 @@ public static int Execute(HashOptions options) return RpfService.ReportError( $"Unknown encoding: {options.Encoding}. Use 'utf-8' or 'ascii'.", options.Json, - result + ErrorResult([]) ); } try { + List hashes = []; + foreach (string input in options.Inputs) { JenkHash jenkHash = new(input, encoding); @@ -121,10 +120,14 @@ public static int Execute(HashOptions options) } } - result = result with { Success = true }; - if (options.Json) { + Json.HashResult result = new() + { + Success = true, + Hashes = hashes.ToArray(), + ErrorMessages = [], + }; Console.WriteLine( JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) ); @@ -134,7 +137,7 @@ public static int Execute(HashOptions options) } catch (Exception ex) { - return RpfService.ReportError(ex.Message, options.Json, result); + return RpfService.ReportError(ex.Message, options.Json, ErrorResult([])); } } } diff --git a/CodeWalker.Cli/Helpers/Filter.cs b/CodeWalker.Cli/Helpers/Filter.cs index f4ebc3b64..709bd4389 100644 --- a/CodeWalker.Cli/Helpers/Filter.cs +++ b/CodeWalker.Cli/Helpers/Filter.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Collections.Generic; using System.Text.RegularExpressions; namespace CodeWalker.Cli.Helpers; @@ -10,11 +11,30 @@ public static class Filter { private static readonly ConcurrentDictionary RegexCache = new(); + /// + /// Normalizes filter patterns once at parse time: trims, lowercases, and strips blanks. + /// + public static string[] Normalize(string[]? filters) + { + if (filters == null || filters.Length == 0) + return []; + + List result = []; + foreach (string filter in filters) + { + if (string.IsNullOrWhiteSpace(filter)) + continue; + result.Add(filter.Trim().ToLowerInvariant()); + } + return result.ToArray(); + } + /// /// Determines if the given path matches any of the provided glob patterns. + /// Filters should be pre-normalized via . /// /// Path to check. - /// Glob patterns to match against. + /// Glob patterns to match against (pre-normalized). /// True if the path matches any pattern; otherwise, false. public static bool Matches(string path, string[]? filters) { @@ -25,12 +45,7 @@ public static bool Matches(string path, string[]? filters) foreach (string filter in filters) { - if (string.IsNullOrWhiteSpace(filter)) - continue; - - string p = filter.Trim().ToLowerInvariant(); - - if (MatchesGlob(nameLower, p)) + if (MatchesGlob(nameLower, filter)) return true; } diff --git a/CodeWalker.Cli/Json/ExportResult.cs b/CodeWalker.Cli/Json/ExportResult.cs new file mode 100644 index 000000000..c5d65a1a1 --- /dev/null +++ b/CodeWalker.Cli/Json/ExportResult.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace CodeWalker.Cli.Json; + +public record ExportFileEntry +{ + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("outputPath")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? OutputPath { get; init; } + + [JsonPropertyName("outputFiles")] + public required int OutputFiles { get; init; } + + [JsonPropertyName("status")] + public required string Status { get; init; } +} + +public record ExportResult : BaseResult +{ + [JsonPropertyName("rpfFile")] + public required string RpfFile { get; init; } + + [JsonPropertyName("outputDir")] + public required string OutputDir { get; init; } + + [JsonPropertyName("format")] + public required string Format { get; init; } + + [JsonPropertyName("totalFiles")] + public required int TotalFiles { get; init; } + + [JsonPropertyName("exported")] + public required int Exported { get; init; } + + [JsonPropertyName("skipped")] + public required int Skipped { get; init; } + + [JsonPropertyName("errors")] + public required int Errors { get; init; } + + [JsonPropertyName("dryRun")] + public required bool DryRun { get; init; } + + [JsonPropertyName("files")] + public required IReadOnlyList Files { get; init; } +} diff --git a/CodeWalker.Cli/ListHandler.cs b/CodeWalker.Cli/ListHandler.cs index d621a2336..ac9ab9769 100644 --- a/CodeWalker.Cli/ListHandler.cs +++ b/CodeWalker.Cli/ListHandler.cs @@ -29,20 +29,18 @@ public static Command CreateCommand() public static int Execute(RpfOptions options) { - List files = []; - List errorMessages = []; - - Json.ListResult result = new() - { - Success = false, - RpfFile = options.RpfPath, - TotalFiles = 0, - TotalSize = 0, - TotalSizeFormatted = "0 B", - NestedRpfCount = 0, - Files = files, - ErrorMessages = errorMessages, - }; + Json.ListResult ErrorResult(string[] errorMessages) => + new() + { + Success = false, + RpfFile = options.RpfPath, + TotalFiles = 0, + TotalSize = 0, + TotalSizeFormatted = "0 B", + NestedRpfCount = 0, + Files = [], + ErrorMessages = errorMessages, + }; string? initError = RpfService.ValidateAndLoadKeys( options.RpfPath, @@ -52,19 +50,20 @@ public static int Execute(RpfOptions options) ); if (initError != null) { - return RpfService.ReportError(initError, options.Json, result); + return RpfService.ReportError(initError, options.Json, ErrorResult([])); } try { + List scanErrors = []; RpfFile rpf = RpfService.OpenRpf( options.RpfPath, options.Verbose, options.Json, - errorMessages + scanErrors ); - result = result with { NestedRpfCount = rpf.GrandTotalRpfCount }; + uint nestedRpfCount = rpf.GrandTotalRpfCount; if (!options.Json) { @@ -127,6 +126,7 @@ public static int Execute(RpfOptions options) // Output results sequentially to preserve order long totalSize = 0; int fileCount = 0; + List files = []; foreach (var (jsonEntry, line, size) in results) { totalSize += size; @@ -138,12 +138,16 @@ public static int Execute(RpfOptions options) Console.WriteLine(line); } - result = result with + Json.ListResult result = new() { - Success = errorMessages.Count == 0, + Success = scanErrors.Count == 0, + RpfFile = options.RpfPath, TotalFiles = fileCount, TotalSize = totalSize, TotalSizeFormatted = options.SizeFormat.ToFormattedString(totalSize), + NestedRpfCount = nestedRpfCount, + Files = files.ToArray(), + ErrorMessages = scanErrors.ToArray(), }; if (options.Json) @@ -167,7 +171,7 @@ public static int Execute(RpfOptions options) return RpfService.ReportError( ex.Message, options.Json, - result, + ErrorResult([]), options.Verbose ? ex.StackTrace : null ); } diff --git a/CodeWalker.Cli/PackHandler.cs b/CodeWalker.Cli/PackHandler.cs index 37b4e6f82..e386b4771 100644 --- a/CodeWalker.Cli/PackHandler.cs +++ b/CodeWalker.Cli/PackHandler.cs @@ -12,19 +12,17 @@ public record PackOptions { public required string InputPath { get; init; } public required string OutputPath { get; init; } - public required string ExePath { get; init; } + public required CommonOptions Common { get; init; } public required bool Gen9 { get; init; } public required bool Force { get; init; } public required bool Progress { get; init; } - public required bool Verbose { get; init; } - public required bool Json { get; init; } - public required SizeFormat SizeFormat { get; init; } } public static class PackHandler { public static Command CreateCommand() { + CommonCommandOptions commonOpts = new(); // csharpier-ignore-start Option inputOption = new("--input", "-i") { @@ -38,12 +36,6 @@ public static Command CreateCommand() Required = true, }; - Option exeOption = new("--exe", "-e") - { - Description = "Path to the GTA V installation directory (containing GTA5.exe)", - Required = true, - }; - Option gen9Option = new("--gen9", "-g") { Description = "Use GTA V Enhanced (Gen9) mode", @@ -58,35 +50,17 @@ public static Command CreateCommand() { Description = "Show progress bar", }; - - Option verboseOption = new("--verbose", "-v") - { - Description = "Show per-file status", - }; - - Option jsonOption = new("--json") - { - Description = "Output results in JSON format", - }; - - Option siOption = new("--si") - { - Description = "Use SI units (1000-based: KB, MB) instead of IEC (1024-based: KiB, MiB)", - }; // csharpier-ignore-end Command command = new("pack", "Create an RPF archive from a directory of loose files") { inputOption, outputOption, - exeOption, gen9Option, forceOption, progressOption, - verboseOption, - jsonOption, - siOption, }; + commonOpts.AddTo(command); command.Aliases.Add("p"); command.SetAction(parseResult => @@ -95,13 +69,10 @@ public static Command CreateCommand() { InputPath = parseResult.GetRequiredValue(inputOption).FullName, OutputPath = parseResult.GetRequiredValue(outputOption).FullName, - ExePath = parseResult.GetRequiredValue(exeOption).FullName, + Common = commonOpts.Parse(parseResult), Gen9 = parseResult.GetValue(gen9Option), Force = parseResult.GetValue(forceOption), Progress = parseResult.GetValue(progressOption), - Verbose = parseResult.GetValue(verboseOption), - Json = parseResult.GetValue(jsonOption), - SizeFormat = parseResult.GetValue(siOption) ? SizeFormat.SI : SizeFormat.IEC, }; return Execute(options); }); @@ -111,27 +82,26 @@ public static Command CreateCommand() public static int Execute(PackOptions options) { - List errorMessages = []; - - Json.PackResult result = new() - { - Success = false, - InputDir = options.InputPath, - OutputFile = options.OutputPath, - TotalFiles = 0, - TotalDirs = 0, - TotalSize = 0, - TotalSizeFormatted = "0 B", - Errors = 0, - ErrorMessages = errorMessages, - }; + Json.PackResult ErrorResult(string[] errorMessages) => + new() + { + Success = false, + InputDir = options.InputPath, + OutputFile = options.OutputPath, + TotalFiles = 0, + TotalDirs = 0, + TotalSize = 0, + TotalSizeFormatted = "0 B", + Errors = 0, + ErrorMessages = errorMessages, + }; if (!Directory.Exists(options.InputPath)) { return RpfService.ReportError( $"Input directory not found: {options.InputPath}", - options.Json, - result + options.Common.Json, + ErrorResult([]) ); } @@ -141,31 +111,25 @@ public static int Execute(PackOptions options) { return RpfService.ReportError( $"Output file already exists: {options.OutputPath}. Use --force to overwrite.", - options.Json, - result + options.Common.Json, + ErrorResult([]) ); } File.Delete(options.OutputPath); } - string exeFile = options.Gen9 ? "GTA5_Enhanced.exe" : "GTA5.exe"; - if (!File.Exists(Path.Combine(options.ExePath, exeFile))) + string? exeError = RpfService.ValidateExeAndLoadKeys( + options.Common.ExePath, + options.Gen9, + options.Common.Json + ); + if (exeError != null) { - return RpfService.ReportError( - $"{exeFile} not found in: {options.ExePath}", - options.Json, - result - ); + return RpfService.ReportError(exeError, options.Common.Json, ErrorResult([])); } try { - if (!options.Json) - { - Console.Error.WriteLine("Loading encryption keys..."); - } - RpfService.LoadKeys(options.ExePath, options.Gen9); - // Count files for progress bar string[] allFiles = Directory.GetFiles( options.InputPath, @@ -173,7 +137,7 @@ public static int Execute(PackOptions options) SearchOption.AllDirectories ); - if (!options.Json) + if (!options.Common.Json) { Console.Error.WriteLine( $"Packing {allFiles.Length} files from {options.InputPath}" @@ -192,7 +156,7 @@ public static int Execute(PackOptions options) RpfFile rpf = RpfFile.CreateNew(outputFolder, outputFileName); - if (!options.Json) + if (!options.Common.Json) { Console.Error.WriteLine($"Created RPF: {options.OutputPath}"); } @@ -201,8 +165,14 @@ public static int Execute(PackOptions options) int totalDirs = 0; long totalSize = 0; int errors = 0; - - using (ProgressBar progress = new(allFiles.Length, options.Progress && !options.Json)) + List errorMessages = []; + + using ( + ProgressBar progress = new( + allFiles.Length, + options.Progress && !options.Common.Json + ) + ) { AddDirectoryContents( rpf.Root, @@ -217,25 +187,28 @@ ref errors ); } - if (!options.Json) + if (!options.Common.Json) { Console.Error.WriteLine("Defragmenting archive..."); } RpfFile.Defragment(rpf); - SizeFormat sizeFormat = options.SizeFormat; + SizeFormat sizeFormat = options.Common.SizeFormat; - result = result with + Json.PackResult result = new() { Success = errors == 0, + InputDir = options.InputPath, + OutputFile = options.OutputPath, TotalFiles = totalFiles, TotalDirs = totalDirs, TotalSize = totalSize, TotalSizeFormatted = sizeFormat.ToFormattedString(totalSize), Errors = errors, + ErrorMessages = errorMessages.ToArray(), }; - if (options.Json) + if (options.Common.Json) { Console.WriteLine( JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) @@ -255,9 +228,9 @@ ref errors { return RpfService.ReportError( ex.Message, - options.Json, - result, - options.Verbose ? ex.StackTrace : null + options.Common.Json, + ErrorResult([]), + options.Common.Verbose ? ex.StackTrace : null ); } } @@ -280,7 +253,7 @@ ref int errors string dirName = Path.GetFileName(subDirPath); try { - if (options.Verbose && !options.Json) + if (options.Common.Verbose && !options.Common.Json) { Console.Error.WriteLine($"Creating directory: {dirName}"); } @@ -305,7 +278,7 @@ ref errors errors++; string errorMsg = $"Error creating directory {dirName}: {ex.Message}"; errorMessages.Add(errorMsg); - if (!options.Json) + if (!options.Common.Json) { Console.Error.WriteLine($"Error: {errorMsg}"); } @@ -320,7 +293,7 @@ ref errors { byte[] data = File.ReadAllBytes(filePath); - if (options.Verbose && !options.Json) + if (options.Common.Verbose && !options.Common.Json) { Console.Error.WriteLine($"Adding file: {fileName} ({data.Length} bytes)"); } @@ -335,7 +308,7 @@ ref errors errors++; string errorMsg = $"Error adding file {fileName}: {ex.Message}"; errorMessages.Add(errorMsg); - if (!options.Json) + if (!options.Common.Json) { Console.Error.WriteLine($"Error: {errorMsg}"); } diff --git a/CodeWalker.Cli/Program.cs b/CodeWalker.Cli/Program.cs index 5a31fe718..f4fc49fc1 100644 --- a/CodeWalker.Cli/Program.cs +++ b/CodeWalker.Cli/Program.cs @@ -10,6 +10,7 @@ Gen9Handler.CreateCommand(), PackHandler.CreateCommand(), DiffHandler.CreateCommand(), + ExportHandler.CreateCommand(), }; return rootCommand.Parse(args).Invoke(); diff --git a/CodeWalker.Cli/RpfOptions.cs b/CodeWalker.Cli/RpfOptions.cs index 9bd9e534e..1d2d41333 100644 --- a/CodeWalker.Cli/RpfOptions.cs +++ b/CodeWalker.Cli/RpfOptions.cs @@ -1,4 +1,3 @@ -using System; using System.CommandLine; using System.IO; using CodeWalker.Cli.Helpers; @@ -25,6 +24,8 @@ public record RpfOptions /// public sealed class RpfCommandOptions { + private readonly CommonCommandOptions _commonOpts = new(); + // csharpier-ignore-start public Option Rpf { get; } = new("--rpf", "-r") { @@ -32,12 +33,6 @@ public sealed class RpfCommandOptions Required = true, }; - public Option Exe { get; } = new("--exe", "-e") - { - Description = "Path to the GTA V installation directory (containing GTA5.exe)", - Required = true, - }; - public Option Gen9 { get; } = new("--gen9", "-g") { Description = "Use GTA V Enhanced (Gen9) mode", @@ -49,59 +44,35 @@ public sealed class RpfCommandOptions AllowMultipleArgumentsPerToken = true, }; - public Option Verbose { get; } = new("--verbose", "-v") - { - Description = "Show verbose output", - }; - - public Option Json { get; } = new("--json") - { - Description = "Output results in JSON format for scripting", - }; - public Option Recursive { get; } = new("--recursive", "-R") { Description = "Process nested RPF archives", }; - - public Option Si { get; } = new("--si") - { - Description = "Use SI units (1000-based: KB, MB) instead of IEC (1024-based: KiB, MiB)", - }; - - public Option Threads { get; } = new("--threads", "-t") - { - Description = "Number of threads for parallel processing", - DefaultValueFactory = _ => Environment.ProcessorCount, - }; // csharpier-ignore-end public void AddTo(Command command) { command.Add(Rpf); - command.Add(Exe); + _commonOpts.AddTo(command); command.Add(Gen9); command.Add(Filter); - command.Add(Verbose); - command.Add(Json); command.Add(Recursive); - command.Add(Si); - command.Add(Threads); } public RpfOptions Parse(ParseResult parseResult) { + CommonOptions common = _commonOpts.Parse(parseResult); return new RpfOptions { RpfPath = parseResult.GetRequiredValue(Rpf).FullName, - ExePath = parseResult.GetRequiredValue(Exe).FullName, + ExePath = common.ExePath, Gen9 = parseResult.GetValue(Gen9), - Filters = parseResult.GetValue(Filter) ?? [], - Verbose = parseResult.GetValue(Verbose), - Json = parseResult.GetValue(Json), + Filters = Helpers.Filter.Normalize(parseResult.GetValue(Filter)), + Verbose = common.Verbose, + Json = common.Json, Recursive = parseResult.GetValue(Recursive), - Threads = parseResult.GetValue(Threads), - SizeFormat = parseResult.GetValue(Si) ? SizeFormat.SI : SizeFormat.IEC, + Threads = common.Threads, + SizeFormat = common.SizeFormat, }; } } diff --git a/CodeWalker.Cli/RpfService.cs b/CodeWalker.Cli/RpfService.cs index 9c4429b82..ee50c984f 100644 --- a/CodeWalker.Cli/RpfService.cs +++ b/CodeWalker.Cli/RpfService.cs @@ -26,6 +26,19 @@ public static class RpfService WriteIndented = true, }; + /// + /// Validates that the GTA V executable exists in the given directory. + /// Returns null on success, or an error message on failure. + /// + public static string? ValidateExe(string exePath, bool gen9) + { + string exeFile = gen9 ? "GTA5_Enhanced.exe" : "GTA5.exe"; + if (!File.Exists(Path.Combine(exePath, exeFile))) + return $"{exeFile} not found in: {exePath}"; + + return null; + } + /// /// Validates that the RPF file and GTA V executable exist. /// Returns null on success, or an error message on failure. @@ -35,9 +48,23 @@ public static class RpfService if (!File.Exists(rpfPath)) return $"RPF file not found: {rpfPath}"; - string exeFile = gen9 ? "GTA5_Enhanced.exe" : "GTA5.exe"; - if (!File.Exists(Path.Combine(exePath, exeFile))) - return $"{exeFile} not found in: {exePath}"; + return ValidateExe(exePath, gen9); + } + + /// + /// Validates the GTA V exe, loads encryption keys, and prints status to stderr. + /// For commands that have no --rpf (e.g. gen9, pack). + /// Returns an error message on failure, or null on success. + /// + public static string? ValidateExeAndLoadKeys(string exePath, bool gen9, bool json) + { + string? error = ValidateExe(exePath, gen9); + if (error != null) + return error; + + if (!json) + Console.Error.WriteLine("Loading encryption keys..."); + LoadKeys(exePath, gen9); return null; } diff --git a/CodeWalker.Cli/TreeHandler.cs b/CodeWalker.Cli/TreeHandler.cs index cd69f3119..8143f9ed5 100644 --- a/CodeWalker.Cli/TreeHandler.cs +++ b/CodeWalker.Cli/TreeHandler.cs @@ -49,16 +49,15 @@ public static Command CreateCommand() public static int Execute(TreeOptions options) { - List errorMessages = []; - - Json.TreeResult result = new() - { - Success = false, - RpfFile = options.Rpf.RpfPath, - TotalFiles = 0, - TotalDirs = 0, - ErrorMessages = errorMessages, - }; + Json.TreeResult ErrorResult(string[] errorMessages) => + new() + { + Success = false, + RpfFile = options.Rpf.RpfPath, + TotalFiles = 0, + TotalDirs = 0, + ErrorMessages = errorMessages, + }; string? initError = RpfService.ValidateAndLoadKeys( options.Rpf.RpfPath, @@ -68,16 +67,17 @@ public static int Execute(TreeOptions options) ); if (initError != null) { - return RpfService.ReportError(initError, options.Rpf.Json, result); + return RpfService.ReportError(initError, options.Rpf.Json, ErrorResult([])); } try { + List scanErrors = []; RpfFile rpf = RpfService.OpenRpf( options.Rpf.RpfPath, options.Rpf.Verbose, options.Rpf.Json, - errorMessages + scanErrors ); int totalFiles = 0; @@ -94,13 +94,14 @@ public static int Execute(TreeOptions options) ref totalDirs ); - result = result with + Json.TreeResult result = new() { Success = true, RpfFile = options.Rpf.RpfPath, TotalFiles = totalFiles, TotalDirs = totalDirs, Root = rootNode, + ErrorMessages = scanErrors.ToArray(), }; Console.WriteLine( @@ -123,7 +124,7 @@ ref totalDirs return RpfService.ReportError( ex.Message, options.Rpf.Json, - result, + ErrorResult([]), options.Rpf.Verbose ? ex.StackTrace : null ); } From 9ae1d44a9eed91c06a0189dedef1c96b9a524219 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:26:32 +0200 Subject: [PATCH 06/45] fix(cli): null and thread safety, and --no-overwrite for export --threads accepted zero and negative values and then passed them to MaxDegreeOfParallelism, where anything below -1 throws. It is validated at parse time now, so the error names the option instead of surfacing as an exception from inside the loop. export ignored --no-overwrite: the flag was defined but never reached the code that writes the output files, so it overwrote regardless. Each exporter takes it and skips the files that already exist. The text exporter dereferenced the parsed .gxt2 without checking that it parsed, so a file it could not read took the process down instead of being reported as unsupported. --- CodeWalker.Cli/CommonOptions.cs | 9 +++++++ CodeWalker.Cli/DiffHandler.cs | 5 +--- CodeWalker.Cli/ExportAudioHandler.cs | 7 +++++- CodeWalker.Cli/ExportHandler.cs | 8 +++++++ CodeWalker.Cli/ExportService.cs | 11 ++++----- CodeWalker.Cli/ExportTextHandler.cs | 31 +++++++++++++++++++++++- CodeWalker.Cli/ExportTexturesHandler.cs | 6 ++++- CodeWalker.Cli/ExportXmlHandler.cs | 18 +++++++++++++- CodeWalker.Cli/ExtractHandler.cs | 7 ++---- CodeWalker.Cli/Gen9Handler.cs | 32 +++++++++++++++++-------- CodeWalker.Cli/Json/ExtractResult.cs | 2 +- CodeWalker.Cli/Json/ListResult.cs | 2 +- CodeWalker.Cli/ListHandler.cs | 4 ++-- CodeWalker.Cli/RpfService.cs | 26 ++++---------------- CodeWalker.Cli/TreeHandler.cs | 6 +++++ 15 files changed, 120 insertions(+), 54 deletions(-) diff --git a/CodeWalker.Cli/CommonOptions.cs b/CodeWalker.Cli/CommonOptions.cs index 33aa17af0..1976743cd 100644 --- a/CodeWalker.Cli/CommonOptions.cs +++ b/CodeWalker.Cli/CommonOptions.cs @@ -50,6 +50,15 @@ public sealed class CommonCommandOptions }; // csharpier-ignore-end + public CommonCommandOptions() + { + Threads.Validators.Add(result => + { + if (result.GetValue(Threads) < 1) + result.AddError("--threads must be at least 1."); + }); + } + public void AddTo(Command command) { command.Add(Exe); diff --git a/CodeWalker.Cli/DiffHandler.cs b/CodeWalker.Cli/DiffHandler.cs index d22ffde62..4d19b4fec 100644 --- a/CodeWalker.Cli/DiffHandler.cs +++ b/CodeWalker.Cli/DiffHandler.cs @@ -183,10 +183,7 @@ Json.DiffResult ErrorResult(string[] errorMessages) => Parallel.For( 0, commonPaths.Length, - new ParallelOptions - { - MaxDegreeOfParallelism = Math.Max(1, options.Common.Threads), - }, + new ParallelOptions { MaxDegreeOfParallelism = options.Common.Threads }, i => { string path = commonPaths[i]; diff --git a/CodeWalker.Cli/ExportAudioHandler.cs b/CodeWalker.Cli/ExportAudioHandler.cs index e5f4c1e96..dc7299cd9 100644 --- a/CodeWalker.Cli/ExportAudioHandler.cs +++ b/CodeWalker.Cli/ExportAudioHandler.cs @@ -33,7 +33,8 @@ public static Command CreateCommand() private static (Json.ExportFileEntry? entry, string? error) ProcessFile( RpfFileEntry fileEntry, byte[] data, - string fileOutputDir + string fileOutputDir, + bool noOverwrite ) { AwcFile awc = RpfFile.GetFile(fileEntry, data); @@ -67,6 +68,8 @@ string fileOutputDir if (stream.MidiChunk?.Data != null) { string midiPath = Path.Combine(fileOutputDir, streamName + ".midi"); + if (noOverwrite && File.Exists(midiPath)) + continue; File.WriteAllBytes(midiPath, stream.MidiChunk.Data); streamCount++; } @@ -74,6 +77,8 @@ string fileOutputDir { byte[] wav = stream.GetWavFile(); string wavPath = Path.Combine(fileOutputDir, streamName + ".wav"); + if (noOverwrite && File.Exists(wavPath)) + continue; File.WriteAllBytes(wavPath, wav); streamCount++; } diff --git a/CodeWalker.Cli/ExportHandler.cs b/CodeWalker.Cli/ExportHandler.cs index 7917616bc..6e2882d44 100644 --- a/CodeWalker.Cli/ExportHandler.cs +++ b/CodeWalker.Cli/ExportHandler.cs @@ -9,6 +9,7 @@ public record ExportOptions public required RpfOptions Rpf { get; init; } public required string OutputPath { get; init; } public required bool DryRun { get; init; } + public required bool NoOverwrite { get; init; } public required bool Progress { get; init; } } @@ -28,6 +29,11 @@ public sealed class ExportCommandOptions Description = "Show what would be exported without writing files", }; + public Option NoOverwrite { get; } = new("--no-overwrite") + { + Description = "Skip existing output files instead of overwriting", + }; + public Option Progress { get; } = new("--progress", "-P") { Description = "Show progress bar during export", @@ -39,6 +45,7 @@ public void AddTo(Command command) _rpfOpts.AddTo(command); command.Add(Output); command.Add(DryRun); + command.Add(NoOverwrite); command.Add(Progress); } @@ -49,6 +56,7 @@ public ExportOptions Parse(ParseResult parseResult) Rpf = _rpfOpts.Parse(parseResult), OutputPath = parseResult.GetValue(Output)?.FullName ?? Directory.GetCurrentDirectory(), DryRun = parseResult.GetValue(DryRun), + NoOverwrite = parseResult.GetValue(NoOverwrite), Progress = parseResult.GetValue(Progress), }; } diff --git a/CodeWalker.Cli/ExportService.cs b/CodeWalker.Cli/ExportService.cs index b48528b3a..918cf7cd0 100644 --- a/CodeWalker.Cli/ExportService.cs +++ b/CodeWalker.Cli/ExportService.cs @@ -19,7 +19,8 @@ namespace CodeWalker.Cli; public delegate (Json.ExportFileEntry? entry, string? error) ExportFileProcessor( RpfFileEntry fileEntry, byte[] data, - string fileOutputDir + string fileOutputDir, + bool noOverwrite ); public static class ExportService @@ -107,10 +108,7 @@ Json.ExportResult ErrorResult(string[] errorMessages) => Parallel.For( 0, filesToExport.Count, - new ParallelOptions - { - MaxDegreeOfParallelism = Math.Max(1, options.Rpf.Threads), - }, + new ParallelOptions { MaxDegreeOfParallelism = options.Rpf.Threads }, i => { (RpfFile sourceRpf, RpfFileEntry fileEntry) = filesToExport[i]; @@ -158,7 +156,8 @@ Json.ExportResult ErrorResult(string[] errorMessages) => (Json.ExportFileEntry? entry, string? error) = processor( fileEntry, data, - fileOutputDir + fileOutputDir, + options.NoOverwrite ); if (error != null) diff --git a/CodeWalker.Cli/ExportTextHandler.cs b/CodeWalker.Cli/ExportTextHandler.cs index 4de6205e8..5d6d6bd7d 100644 --- a/CodeWalker.Cli/ExportTextHandler.cs +++ b/CodeWalker.Cli/ExportTextHandler.cs @@ -34,10 +34,25 @@ public static Command CreateCommand() private static (Json.ExportFileEntry? entry, string? error) ProcessFile( RpfFileEntry fileEntry, byte[] data, - string fileOutputDir + string fileOutputDir, + bool noOverwrite ) { Gxt2File gxt = RpfFile.GetFile(fileEntry, data); + if (gxt == null) + { + return ( + new Json.ExportFileEntry + { + Path = fileEntry.Path, + Name = fileEntry.Name, + OutputFiles = 0, + Status = "skipped", + }, + null + ); + } + string text = gxt.ToText(); if (string.IsNullOrEmpty(text)) @@ -62,6 +77,20 @@ string fileOutputDir string outputFileName = Path.GetFileNameWithoutExtension(fileEntry.Name) + ".txt"; string outputPath = Path.Combine(fileOutputDir, outputFileName); + if (noOverwrite && File.Exists(outputPath)) + { + return ( + new Json.ExportFileEntry + { + Path = fileEntry.Path, + Name = fileEntry.Name, + OutputFiles = 0, + Status = "skipped", + }, + null + ); + } + File.WriteAllText(outputPath, text, Encoding.UTF8); return ( diff --git a/CodeWalker.Cli/ExportTexturesHandler.cs b/CodeWalker.Cli/ExportTexturesHandler.cs index ffc11eb6f..1968b5dc0 100644 --- a/CodeWalker.Cli/ExportTexturesHandler.cs +++ b/CodeWalker.Cli/ExportTexturesHandler.cs @@ -34,7 +34,8 @@ public static Command CreateCommand() private static (Json.ExportFileEntry? entry, string? error) ProcessFile( RpfFileEntry fileEntry, byte[] data, - string fileOutputDir + string fileOutputDir, + bool noOverwrite ) { YtdFile ytd = RpfFile.GetFile(fileEntry, data); @@ -66,6 +67,9 @@ string fileOutputDir string texName = (tex.Name ?? "unknown") + ".dds"; string outputPath = Path.Combine(fileOutputDir, texName); + if (noOverwrite && File.Exists(outputPath)) + continue; + byte[] dds = DDSIO.GetDDSFile(tex); File.WriteAllBytes(outputPath, dds); texCount++; diff --git a/CodeWalker.Cli/ExportXmlHandler.cs b/CodeWalker.Cli/ExportXmlHandler.cs index 0035f8f95..ac14f59ae 100644 --- a/CodeWalker.Cli/ExportXmlHandler.cs +++ b/CodeWalker.Cli/ExportXmlHandler.cs @@ -27,7 +27,8 @@ public static Command CreateCommand() private static (Json.ExportFileEntry? entry, string? error) ProcessFile( RpfFileEntry fileEntry, byte[] data, - string fileOutputDir + string fileOutputDir, + bool noOverwrite ) { string xml = MetaXml.GetXml(fileEntry, data, out string filename, fileOutputDir); @@ -52,6 +53,21 @@ string fileOutputDir } string outputPath = Path.Combine(fileOutputDir, filename); + + if (noOverwrite && File.Exists(outputPath)) + { + return ( + new Json.ExportFileEntry + { + Path = fileEntry.Path, + Name = fileEntry.Name, + OutputFiles = 0, + Status = "skipped", + }, + null + ); + } + File.WriteAllText(outputPath, xml, Encoding.UTF8); return ( diff --git a/CodeWalker.Cli/ExtractHandler.cs b/CodeWalker.Cli/ExtractHandler.cs index 2cfd83679..947690ee1 100644 --- a/CodeWalker.Cli/ExtractHandler.cs +++ b/CodeWalker.Cli/ExtractHandler.cs @@ -111,7 +111,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => scanErrors ); - uint grandTotalFileCount = rpf.GrandTotalFileCount; + int grandTotalFileCount = (int)rpf.GrandTotalFileCount; if (!options.Rpf.Json && options.DryRun) { @@ -156,10 +156,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => Parallel.For( 0, filesToExtract.Count, - new ParallelOptions - { - MaxDegreeOfParallelism = Math.Max(1, options.Rpf.Threads), - }, + new ParallelOptions { MaxDegreeOfParallelism = options.Rpf.Threads }, i => { (RpfFile sourceRpf, RpfFileEntry fileEntry) = filesToExtract[i]; diff --git a/CodeWalker.Cli/Gen9Handler.cs b/CodeWalker.Cli/Gen9Handler.cs index d2674b192..1af309160 100644 --- a/CodeWalker.Cli/Gen9Handler.cs +++ b/CodeWalker.Cli/Gen9Handler.cs @@ -210,13 +210,12 @@ Json.Gen9Result ErrorResult(string[] errorMessages) => string? )[filePaths.Count]; + object consoleLock = new(); + Parallel.For( 0, filePaths.Count, - new ParallelOptions - { - MaxDegreeOfParallelism = Math.Max(1, options.Common.Threads), - }, + new ParallelOptions { MaxDegreeOfParallelism = options.Common.Threads }, i => { string path = filePaths[i]; @@ -238,7 +237,12 @@ Json.Gen9Result ErrorResult(string[] errorMessages) => ); if (options.Common.Verbose && !options.Common.Json) { - Console.Error.WriteLine($"{relPath} - skipped (exists)"); + lock (consoleLock) + { + Console.Error.WriteLine( + $"{relPath} - skipped (exists)" + ); + } } progress.Increment(relPath); return; @@ -252,20 +256,25 @@ Json.Gen9Result ErrorResult(string[] errorMessages) => string ext = Path.GetExtension(path).ToLowerInvariant(); byte[] dataIn = File.ReadAllBytes(path); - byte[] dataOut = Gen9Converter.TryConvert( + byte[]? dataOut = Gen9Converter.TryConvert( dataIn, ext, msg => { if (options.Common.Verbose && !options.Common.Json) - Console.Error.WriteLine(msg); + { + lock (consoleLock) + { + Console.Error.WriteLine(msg); + } + } }, relPath, copyUnconverted, out bool wasConverted ); - if (wasConverted) + if (wasConverted && dataOut != null) { File.WriteAllBytes(outPath, dataOut); nonRpfResults[i] = ( @@ -317,7 +326,10 @@ out bool wasConverted ); if (!options.Common.Json) { - Console.Error.WriteLine($"Error: {errorMsg}"); + lock (consoleLock) + { + Console.Error.WriteLine($"Error: {errorMsg}"); + } } progress.Increment(); } @@ -548,7 +560,7 @@ ref int errors dataIn = ResourceBuilder.Compress(dataIn); dataIn = ResourceBuilder.AddResourceHeader(rfe, dataIn); - byte[] dataOut = Gen9Converter.TryConvert( + byte[]? dataOut = Gen9Converter.TryConvert( dataIn, type, msg => diff --git a/CodeWalker.Cli/Json/ExtractResult.cs b/CodeWalker.Cli/Json/ExtractResult.cs index 638b7c7bd..80dd493a2 100644 --- a/CodeWalker.Cli/Json/ExtractResult.cs +++ b/CodeWalker.Cli/Json/ExtractResult.cs @@ -12,7 +12,7 @@ public record ExtractResult : BaseResult public required string OutputDir { get; init; } [JsonPropertyName("totalFiles")] - public required uint TotalFiles { get; init; } + public required int TotalFiles { get; init; } [JsonPropertyName("extracted")] public required int Extracted { get; init; } diff --git a/CodeWalker.Cli/Json/ListResult.cs b/CodeWalker.Cli/Json/ListResult.cs index ac583efb6..c8d235a64 100644 --- a/CodeWalker.Cli/Json/ListResult.cs +++ b/CodeWalker.Cli/Json/ListResult.cs @@ -18,7 +18,7 @@ public record ListResult : BaseResult public required string TotalSizeFormatted { get; init; } [JsonPropertyName("nestedRpfCount")] - public required uint NestedRpfCount { get; init; } + public required int NestedRpfCount { get; init; } [JsonPropertyName("files")] public required IReadOnlyList Files { get; init; } diff --git a/CodeWalker.Cli/ListHandler.cs b/CodeWalker.Cli/ListHandler.cs index ac9ab9769..f8257f9bf 100644 --- a/CodeWalker.Cli/ListHandler.cs +++ b/CodeWalker.Cli/ListHandler.cs @@ -63,7 +63,7 @@ Json.ListResult ErrorResult(string[] errorMessages) => scanErrors ); - uint nestedRpfCount = rpf.GrandTotalRpfCount; + int nestedRpfCount = (int)rpf.GrandTotalRpfCount; if (!options.Json) { @@ -87,7 +87,7 @@ Json.ListResult ErrorResult(string[] errorMessages) => Parallel.For( 0, entries.Count, - new ParallelOptions { MaxDegreeOfParallelism = Math.Max(1, options.Threads) }, + new ParallelOptions { MaxDegreeOfParallelism = options.Threads }, i => { RpfFileEntry fileEntry = entries[i].entry; diff --git a/CodeWalker.Cli/RpfService.cs b/CodeWalker.Cli/RpfService.cs index ee50c984f..b7b98e4e1 100644 --- a/CodeWalker.Cli/RpfService.cs +++ b/CodeWalker.Cli/RpfService.cs @@ -77,23 +77,6 @@ public static void LoadKeys(string exePath, bool gen9) GTA5Keys.LoadFromPath(exePath, gen9); } - /// - /// Opens an RPF file and scans its structure. - /// - public static RpfFile OpenRpf( - string rpfPath, - Action? onStatus = null, - Action? onError = null - ) - { - string rpfName = Path.GetFileName(rpfPath); - RpfFile rpf = new(rpfPath, rpfName); - - rpf.ScanStructure(status => onStatus?.Invoke(status), error => onError?.Invoke(error)); - - return rpf; - } - /// /// Recursively collects file entries from an RPF archive, applying glob filters. /// @@ -210,14 +193,15 @@ List errorMessages if (!json) Console.Error.WriteLine($"Opening RPF: {rpfPath}"); - RpfFile rpf = OpenRpf( - rpfPath, - onStatus: status => + string rpfName = Path.GetFileName(rpfPath); + RpfFile rpf = new(rpfPath, rpfName); + rpf.ScanStructure( + status => { if (verbose && !json) Console.Error.WriteLine(status); }, - onError: error => + error => { if (!json) Console.Error.WriteLine($"Error: {error}"); diff --git a/CodeWalker.Cli/TreeHandler.cs b/CodeWalker.Cli/TreeHandler.cs index 8143f9ed5..73a398c64 100644 --- a/CodeWalker.Cli/TreeHandler.cs +++ b/CodeWalker.Cli/TreeHandler.cs @@ -27,6 +27,12 @@ public static Command CreateCommand() }; // csharpier-ignore-end + depthOption.Validators.Add(result => + { + if (result.GetValue(depthOption) < -1) + result.AddError("--depth must be -1 (unlimited) or a non-negative integer."); + }); + Command command = new("tree", "Display a visual tree of the RPF directory structure") { depthOption, From 4c97e9f5ea54ebc3b281400bbdd417dd2b505657 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:26:32 +0200 Subject: [PATCH 07/45] feat(cli): add the stat, search, validate and inspect commands stat summarises an archive: file counts, total size, compression ratio and a breakdown per extension. search finds entries by path. validate walks every entry and reports the ones that fail to parse. inspect prints what is known about a single entry, including the resource version and the system and graphics page sizes for resources. That is the set the GUI offers for looking at an archive without opening one of its files. --- CodeWalker.Cli/Compiler.cs | 13 + CodeWalker.Cli/InspectHandler.cs | 631 ++++++++++++++++++++++++++ CodeWalker.Cli/Json/InspectResult.cs | 256 +++++++++++ CodeWalker.Cli/Json/SearchResult.cs | 46 ++ CodeWalker.Cli/Json/StatResult.cs | 61 +++ CodeWalker.Cli/Json/ValidateResult.cs | 44 ++ CodeWalker.Cli/Program.cs | 4 + CodeWalker.Cli/SearchHandler.cs | 258 +++++++++++ CodeWalker.Cli/StatHandler.cs | 204 +++++++++ CodeWalker.Cli/ValidateHandler.cs | 340 ++++++++++++++ 10 files changed, 1857 insertions(+) create mode 100644 CodeWalker.Cli/InspectHandler.cs create mode 100644 CodeWalker.Cli/Json/InspectResult.cs create mode 100644 CodeWalker.Cli/Json/SearchResult.cs create mode 100644 CodeWalker.Cli/Json/StatResult.cs create mode 100644 CodeWalker.Cli/Json/ValidateResult.cs create mode 100644 CodeWalker.Cli/SearchHandler.cs create mode 100644 CodeWalker.Cli/StatHandler.cs create mode 100644 CodeWalker.Cli/ValidateHandler.cs diff --git a/CodeWalker.Cli/Compiler.cs b/CodeWalker.Cli/Compiler.cs index 58c301c74..8ed39db31 100644 --- a/CodeWalker.Cli/Compiler.cs +++ b/CodeWalker.Cli/Compiler.cs @@ -52,3 +52,16 @@ namespace System.Diagnostics.CodeAnalysis internal sealed class SetsRequiredMembersAttribute : Attribute { } #endif } + +#if !NETCOREAPP +namespace CodeWalker.Cli.Polyfills +{ + internal static class StringExtensions + { + public static bool Contains(this string s, char value) + { + return s.IndexOf(value) >= 0; + } + } +} +#endif diff --git a/CodeWalker.Cli/InspectHandler.cs b/CodeWalker.Cli/InspectHandler.cs new file mode 100644 index 000000000..c26501149 --- /dev/null +++ b/CodeWalker.Cli/InspectHandler.cs @@ -0,0 +1,631 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.IO; +using System.Linq; +using System.Text.Json; +using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; +using SharpDX; + +namespace CodeWalker.Cli; + +public static class InspectHandler +{ + public static Command CreateCommand() + { + RpfCommandOptions rpfOpts = new(); + Argument pathArg = new("path") + { + Description = "Path of the file within the RPF archive", + }; + + Command command = new( + "inspect", + "Show detailed metadata for a specific file in an RPF archive" + ) + { + pathArg, + }; + rpfOpts.AddTo(command); + command.Aliases.Add("i"); + + command.SetAction(parseResult => + { + return Execute(rpfOpts.Parse(parseResult), parseResult.GetRequiredValue(pathArg)); + }); + + return command; + } + + public static int Execute(RpfOptions options, string filePath) + { + Json.InspectResult ErrorResult(string[] errorMessages) => + new() + { + Success = false, + RpfFile = options.RpfPath, + Path = filePath, + Name = "", + Size = 0, + SizeFormatted = "0 B", + Type = "", + Extension = "", + NameHash = 0, + ShortNameHash = 0, + ErrorMessages = errorMessages, + }; + + string? initError = RpfService.ValidateAndLoadKeys( + options.RpfPath, + options.ExePath, + options.Gen9, + options.Json + ); + if (initError != null) + { + return RpfService.ReportError(initError, options.Json, ErrorResult([])); + } + + try + { + List scanErrors = []; + RpfFile rpf = RpfService.OpenRpf( + options.RpfPath, + options.Verbose, + options.Json, + scanErrors + ); + + if (!options.Json) + { + Console.Error.WriteLine(); + } + + // Find entry by normalized path + string normalizedPath = filePath.Replace('\\', '/').ToLowerInvariant(); + RpfFileEntry? found = FindEntry(rpf, normalizedPath, options.Recursive); + + if (found == null) + { + return RpfService.ReportError( + $"File not found in archive: {filePath}", + options.Json, + ErrorResult([]) + ); + } + + long size = found.GetFileSize(); + string ext = Path.GetExtension(found.Name).ToLowerInvariant(); + string fileType = RpfService.GetFileType(found); + + // Build base result + Json.InspectResult result = new() + { + Success = scanErrors.Count == 0, + RpfFile = options.RpfPath, + Path = found.Path, + Name = found.Name, + Size = size, + SizeFormatted = options.SizeFormat.ToFormattedString(size), + Type = fileType, + Extension = ext, + NameHash = found.NameHash, + ShortNameHash = found.ShortNameHash, + ResourceVersion = found is RpfResourceFileEntry rfe1 ? rfe1.Version : null, + SystemSize = found is RpfResourceFileEntry rfe2 ? rfe2.SystemSize : null, + GraphicsSize = found is RpfResourceFileEntry rfe3 ? rfe3.GraphicsSize : null, + UncompressedSize = found is RpfBinaryFileEntry bfe1 + ? bfe1.FileUncompressedSize + : null, + EncryptionType = found is RpfBinaryFileEntry bfe2 ? bfe2.EncryptionType : null, + Details = GetDetails(found, ext), + ErrorMessages = scanErrors.ToArray(), + }; + + if (options.Json) + { + Console.WriteLine( + JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + ); + } + else + { + PrintTextResult(result, options); + } + + return 0; + } + catch (Exception ex) + { + return RpfService.ReportError( + ex.Message, + options.Json, + ErrorResult([]), + options.Verbose ? ex.StackTrace : null + ); + } + } + + private static RpfFileEntry? FindEntry(RpfFile rpf, string normalizedPath, bool recursive) + { + if (rpf.AllEntries != null) + { + foreach (RpfEntry entry in rpf.AllEntries) + { + if ( + entry is RpfFileEntry fileEntry + && entry.Path != null + && entry.Path.Replace('\\', '/').ToLowerInvariant() == normalizedPath + ) + { + return fileEntry; + } + } + } + + if (recursive && rpf.Children != null) + { + foreach (RpfFile child in rpf.Children) + { + RpfFileEntry? found = FindEntry(child, normalizedPath, recursive); + if (found != null) + return found; + } + } + + return null; + } + + private static object? GetDetails(RpfFileEntry entry, string ext) + { + try + { + switch (ext) + { + case ".ytd": + return GetYtdDetails(entry); + case ".ydr": + return GetYdrDetails(entry); + case ".ydd": + return GetYddDetails(entry); + case ".yft": + return GetYftDetails(entry); + case ".ymap": + return GetYmapDetails(entry); + case ".ytyp": + return GetYtypDetails(entry); + case ".ybn": + return GetYbnDetails(entry); + case ".awc": + return GetAwcDetails(entry); + case ".gxt2": + return GetGxt2Details(entry); + default: + return null; + } + } + catch + { + return null; + } + } + + private static Json.YtdDetails? GetYtdDetails(RpfFileEntry entry) + { + YtdFile file = RpfFile.GetFile(entry); + if (file?.TextureDict?.Textures?.data_items == null) + return null; + + var textures = file.TextureDict.Textures.data_items; + List infos = []; + foreach (var tex in textures) + { + if (tex == null) + continue; + infos.Add( + new Json.TextureInfo + { + Name = tex.Name ?? "", + Width = tex.Width, + Height = tex.Height, + Format = tex.Format.ToString(), + MipLevels = tex.Levels, + Stride = tex.Stride, + } + ); + } + + return new Json.YtdDetails { TextureCount = infos.Count, Textures = infos }; + } + + private static Json.YdrDetails? GetYdrDetails(RpfFileEntry entry) + { + YdrFile file = RpfFile.GetFile(entry); + if (file?.Drawable?.DrawableModels == null) + return null; + + return new Json.YdrDetails { Lods = GetLodInfos(file.Drawable.DrawableModels) }; + } + + private static Json.YddDetails? GetYddDetails(RpfFileEntry entry) + { + YddFile file = RpfFile.GetFile(entry); + if (file?.DrawableDict?.Drawables?.data_items == null) + return null; + + var drawables = file.DrawableDict.Drawables.data_items; + List infos = []; + foreach (var d in drawables) + { + if (d == null) + continue; + long verts = 0; + long tris = 0; + if (d.AllModels != null) + { + foreach (var model in d.AllModels) + { + if (model?.Geometries == null) + continue; + foreach (var geom in model.Geometries) + { + verts += geom.VerticesCount; + tris += geom.TrianglesCount; + } + } + } + infos.Add( + new Json.DrawableInfo + { + Name = d.Name ?? "", + TotalVertices = verts, + TotalTriangles = tris, + } + ); + } + + return new Json.YddDetails { DrawableCount = infos.Count, Drawables = infos }; + } + + private static Json.YftDetails? GetYftDetails(RpfFileEntry entry) + { + YftFile file = RpfFile.GetFile(entry); + if (file?.Fragment == null) + return null; + + List lods = []; + if (file.Fragment.Drawable?.DrawableModels != null) + { + lods = GetLodInfos(file.Fragment.Drawable.DrawableModels); + } + + return new Json.YftDetails + { + Lods = lods, + HasDrawableCloth = file.Fragment.DrawableCloth != null, + }; + } + + private static Json.YmapDetails? GetYmapDetails(RpfFileEntry entry) + { + YmapFile file = RpfFile.GetFile(entry); + if (file == null) + return null; + + string? entExtMin = null; + string? entExtMax = null; + string? strExtMin = null; + string? strExtMax = null; + + if (file._CMapData.entitiesExtentsMin != default(Vector3)) + entExtMin = FormatVector3(file._CMapData.entitiesExtentsMin); + if (file._CMapData.entitiesExtentsMax != default(Vector3)) + entExtMax = FormatVector3(file._CMapData.entitiesExtentsMax); + if (file._CMapData.streamingExtentsMin != default(Vector3)) + strExtMin = FormatVector3(file._CMapData.streamingExtentsMin); + if (file._CMapData.streamingExtentsMax != default(Vector3)) + strExtMax = FormatVector3(file._CMapData.streamingExtentsMax); + + return new Json.YmapDetails + { + EntityCount = file.AllEntities?.Length ?? 0, + CarGeneratorCount = file.CarGenerators?.Length ?? 0, + EntitiesExtentsMin = entExtMin, + EntitiesExtentsMax = entExtMax, + StreamingExtentsMin = strExtMin, + StreamingExtentsMax = strExtMax, + IsScripted = file.IsScripted, + }; + } + + private static Json.YtypDetails? GetYtypDetails(RpfFileEntry entry) + { + YtypFile file = RpfFile.GetFile(entry); + if (file?.AllArchetypes == null) + return null; + + int baseCount = 0; + int timeCount = 0; + int mloCount = 0; + List mloDetails = []; + + foreach (var arch in file.AllArchetypes) + { + if (arch is MloArchetype mlo) + { + mloCount++; + mloDetails.Add( + new Json.MloInfo + { + Name = mlo.Hash.ToString(), + EntityCount = mlo.entities?.Length ?? 0, + RoomCount = mlo.rooms?.Length ?? 0, + PortalCount = mlo.portals?.Length ?? 0, + } + ); + } + else if (arch is TimeArchetype) + { + timeCount++; + } + else + { + baseCount++; + } + } + + return new Json.YtypDetails + { + ArchetypeCount = file.AllArchetypes.Length, + BaseCount = baseCount, + TimeCount = timeCount, + MloCount = mloCount, + MloDetails = mloDetails.Count > 0 ? mloDetails : null, + }; + } + + private static Json.YbnDetails? GetYbnDetails(RpfFileEntry entry) + { + YbnFile file = RpfFile.GetFile(entry); + if (file?.Bounds == null) + return null; + + int? childCount = null; + if (file.Bounds is BoundComposite composite) + { + childCount = composite.Children?.data_items?.Length ?? 0; + } + + return new Json.YbnDetails + { + BoundsType = file.Bounds.Type.ToString(), + ChildCount = childCount, + }; + } + + private static Json.AwcDetails? GetAwcDetails(RpfFileEntry entry) + { + AwcFile file = RpfFile.GetFile(entry); + if (file?.Streams == null) + return null; + + List infos = []; + foreach (var stream in file.Streams) + { + if (stream?.StreamInfo == null) + continue; + + var fmt = stream.FormatChunk; + infos.Add( + new Json.AwcStreamInfo + { + Id = stream.StreamInfo.Id, + SamplesPerSecond = fmt?.SamplesPerSecond ?? 0, + Codec = fmt?.Codec.ToString() ?? "unknown", + Samples = fmt?.Samples ?? 0, + } + ); + } + + return new Json.AwcDetails { StreamCount = infos.Count, Streams = infos }; + } + + private static Json.Gxt2Details? GetGxt2Details(RpfFileEntry entry) + { + Gxt2File file = RpfFile.GetFile(entry); + if (file?.TextEntries == null) + return null; + + List infos = []; + int limit = Math.Min(file.TextEntries.Length, 50); + for (int i = 0; i < limit; i++) + { + var e = file.TextEntries[i]; + string text = e.Text ?? ""; + if (text.Length > 100) + text = text.Substring(0, 100) + "..."; + + infos.Add(new Json.Gxt2EntryInfo { Hash = $"0x{e.Hash:X8}", Text = text }); + } + + return new Json.Gxt2Details { EntryCount = file.TextEntries.Length, Entries = infos }; + } + + private static List GetLodInfos(DrawableModelsBlock models) + { + List lods = []; + AddLod(lods, "High", models.High); + AddLod(lods, "Med", models.Med); + AddLod(lods, "Low", models.Low); + AddLod(lods, "VLow", models.VLow); + return lods; + } + + private static void AddLod(List lods, string level, DrawableModel[]? models) + { + if (models == null || models.Length == 0) + return; + + int geomCount = 0; + long totalVerts = 0; + long totalTris = 0; + + foreach (var model in models) + { + if (model?.Geometries == null) + continue; + geomCount += model.Geometries.Length; + foreach (var geom in model.Geometries) + { + totalVerts += geom.VerticesCount; + totalTris += geom.TrianglesCount; + } + } + + lods.Add( + new Json.LodInfo + { + Level = level, + ModelCount = models.Length, + GeometryCount = geomCount, + TotalVertices = totalVerts, + TotalTriangles = totalTris, + } + ); + } + + private static string FormatVector3(Vector3 v) + { + return $"{v.X:F2}, {v.Y:F2}, {v.Z:F2}"; + } + + private static void PrintTextResult(Json.InspectResult result, RpfOptions options) + { + Console.WriteLine($"Path: {result.Path}"); + Console.WriteLine($"Name: {result.Name}"); + Console.WriteLine($"Size: {result.SizeFormatted} ({result.Size} bytes)"); + Console.WriteLine($"Type: {result.Type}"); + Console.WriteLine($"Extension: {result.Extension}"); + Console.WriteLine($"NameHash: 0x{result.NameHash:X8}"); + Console.WriteLine($"ShortHash: 0x{result.ShortNameHash:X8}"); + + if (result.ResourceVersion != null) + { + Console.WriteLine($"Version: {result.ResourceVersion}"); + Console.WriteLine($"SystemSize: {result.SystemSize}"); + Console.WriteLine($"GraphSize: {result.GraphicsSize}"); + } + + if (result.UncompressedSize != null) + { + Console.WriteLine( + $"Uncompressed: {options.SizeFormat.ToFormattedString(result.UncompressedSize.Value)}" + ); + Console.WriteLine($"Encryption: {result.EncryptionType}"); + } + + if (result.Details == null) + return; + + Console.WriteLine(); + + switch (result.Details) + { + case Json.YtdDetails ytd: + Console.WriteLine($"Textures: {ytd.TextureCount}"); + foreach (var tex in ytd.Textures) + { + Console.WriteLine( + $" {tex.Name}: {tex.Width}x{tex.Height} {tex.Format} mips={tex.MipLevels} stride={tex.Stride}" + ); + } + break; + + case Json.YdrDetails ydr: + PrintLods(ydr.Lods); + break; + + case Json.YddDetails ydd: + Console.WriteLine($"Drawables: {ydd.DrawableCount}"); + foreach (var d in ydd.Drawables) + { + Console.WriteLine( + $" {d.Name}: {d.TotalVertices} vertices, {d.TotalTriangles} triangles" + ); + } + break; + + case Json.YftDetails yft: + PrintLods(yft.Lods); + Console.WriteLine($"DrawableCloth: {(yft.HasDrawableCloth ? "yes" : "no")}"); + break; + + case Json.YmapDetails ymap: + Console.WriteLine($"Entities: {ymap.EntityCount}"); + Console.WriteLine($"Car Generators: {ymap.CarGeneratorCount}"); + if (ymap.EntitiesExtentsMin != null) + Console.WriteLine( + $"Entity Extents: [{ymap.EntitiesExtentsMin}] to [{ymap.EntitiesExtentsMax}]" + ); + if (ymap.StreamingExtentsMin != null) + Console.WriteLine( + $"Stream Extents: [{ymap.StreamingExtentsMin}] to [{ymap.StreamingExtentsMax}]" + ); + Console.WriteLine($"Scripted: {(ymap.IsScripted ? "yes" : "no")}"); + break; + + case Json.YtypDetails ytyp: + Console.WriteLine($"Archetypes: {ytyp.ArchetypeCount}"); + Console.WriteLine( + $" Base: {ytyp.BaseCount}, Time: {ytyp.TimeCount}, MLO: {ytyp.MloCount}" + ); + if (ytyp.MloDetails != null) + { + foreach (var mlo in ytyp.MloDetails) + { + Console.WriteLine( + $" MLO {mlo.Name}: {mlo.EntityCount} entities, {mlo.RoomCount} rooms, {mlo.PortalCount} portals" + ); + } + } + break; + + case Json.YbnDetails ybn: + Console.WriteLine($"Bounds Type: {ybn.BoundsType}"); + if (ybn.ChildCount != null) + Console.WriteLine($"Children: {ybn.ChildCount}"); + break; + + case Json.AwcDetails awc: + Console.WriteLine($"Streams: {awc.StreamCount}"); + foreach (var s in awc.Streams) + { + Console.WriteLine( + $" Stream {s.Id}: {s.Codec} {s.SamplesPerSecond}Hz {s.Samples} samples" + ); + } + break; + + case Json.Gxt2Details gxt2: + Console.WriteLine($"Text Entries: {gxt2.EntryCount}"); + foreach (var e in gxt2.Entries) + { + Console.WriteLine($" {e.Hash}: {e.Text}"); + } + if (gxt2.EntryCount > gxt2.Entries.Count) + Console.WriteLine($" ... and {gxt2.EntryCount - gxt2.Entries.Count} more"); + break; + } + } + + private static void PrintLods(IReadOnlyList lods) + { + foreach (var lod in lods) + { + Console.WriteLine( + $" {lod.Level}: {lod.ModelCount} models, {lod.GeometryCount} geometries, {lod.TotalVertices} vertices, {lod.TotalTriangles} triangles" + ); + } + } +} diff --git a/CodeWalker.Cli/Json/InspectResult.cs b/CodeWalker.Cli/Json/InspectResult.cs new file mode 100644 index 000000000..17b0a5fcf --- /dev/null +++ b/CodeWalker.Cli/Json/InspectResult.cs @@ -0,0 +1,256 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace CodeWalker.Cli.Json; + +public record InspectResult : BaseResult +{ + [JsonPropertyName("rpfFile")] + public required string RpfFile { get; init; } + + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("size")] + public required long Size { get; init; } + + [JsonPropertyName("sizeFormatted")] + public required string SizeFormatted { get; init; } + + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("extension")] + public required string Extension { get; init; } + + [JsonPropertyName("nameHash")] + public required uint NameHash { get; init; } + + [JsonPropertyName("shortNameHash")] + public required uint ShortNameHash { get; init; } + + [JsonPropertyName("resourceVersion")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? ResourceVersion { get; init; } + + [JsonPropertyName("systemSize")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? SystemSize { get; init; } + + [JsonPropertyName("graphicsSize")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? GraphicsSize { get; init; } + + [JsonPropertyName("uncompressedSize")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public uint? UncompressedSize { get; init; } + + [JsonPropertyName("encryptionType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public uint? EncryptionType { get; init; } + + [JsonPropertyName("details")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Details { get; init; } +} + +public record TextureInfo +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("width")] + public required ushort Width { get; init; } + + [JsonPropertyName("height")] + public required ushort Height { get; init; } + + [JsonPropertyName("format")] + public required string Format { get; init; } + + [JsonPropertyName("mipLevels")] + public required byte MipLevels { get; init; } + + [JsonPropertyName("stride")] + public required ushort Stride { get; init; } +} + +public record YtdDetails +{ + [JsonPropertyName("textureCount")] + public required int TextureCount { get; init; } + + [JsonPropertyName("textures")] + public required IReadOnlyList Textures { get; init; } +} + +public record LodInfo +{ + [JsonPropertyName("level")] + public required string Level { get; init; } + + [JsonPropertyName("modelCount")] + public required int ModelCount { get; init; } + + [JsonPropertyName("geometryCount")] + public required int GeometryCount { get; init; } + + [JsonPropertyName("totalVertices")] + public required long TotalVertices { get; init; } + + [JsonPropertyName("totalTriangles")] + public required long TotalTriangles { get; init; } +} + +public record YdrDetails +{ + [JsonPropertyName("lods")] + public required IReadOnlyList Lods { get; init; } +} + +public record DrawableInfo +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("totalVertices")] + public required long TotalVertices { get; init; } + + [JsonPropertyName("totalTriangles")] + public required long TotalTriangles { get; init; } +} + +public record YddDetails +{ + [JsonPropertyName("drawableCount")] + public required int DrawableCount { get; init; } + + [JsonPropertyName("drawables")] + public required IReadOnlyList Drawables { get; init; } +} + +public record YftDetails +{ + [JsonPropertyName("lods")] + public required IReadOnlyList Lods { get; init; } + + [JsonPropertyName("hasDrawableCloth")] + public required bool HasDrawableCloth { get; init; } +} + +public record YmapDetails +{ + [JsonPropertyName("entityCount")] + public required int EntityCount { get; init; } + + [JsonPropertyName("carGeneratorCount")] + public required int CarGeneratorCount { get; init; } + + [JsonPropertyName("entitiesExtentsMin")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EntitiesExtentsMin { get; init; } + + [JsonPropertyName("entitiesExtentsMax")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EntitiesExtentsMax { get; init; } + + [JsonPropertyName("streamingExtentsMin")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? StreamingExtentsMin { get; init; } + + [JsonPropertyName("streamingExtentsMax")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? StreamingExtentsMax { get; init; } + + [JsonPropertyName("isScripted")] + public required bool IsScripted { get; init; } +} + +public record YtypDetails +{ + [JsonPropertyName("archetypeCount")] + public required int ArchetypeCount { get; init; } + + [JsonPropertyName("baseCount")] + public required int BaseCount { get; init; } + + [JsonPropertyName("timeCount")] + public required int TimeCount { get; init; } + + [JsonPropertyName("mloCount")] + public required int MloCount { get; init; } + + [JsonPropertyName("mloDetails")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? MloDetails { get; init; } +} + +public record MloInfo +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("entityCount")] + public required int EntityCount { get; init; } + + [JsonPropertyName("roomCount")] + public required int RoomCount { get; init; } + + [JsonPropertyName("portalCount")] + public required int PortalCount { get; init; } +} + +public record YbnDetails +{ + [JsonPropertyName("boundsType")] + public required string BoundsType { get; init; } + + [JsonPropertyName("childCount")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? ChildCount { get; init; } +} + +public record AwcStreamInfo +{ + [JsonPropertyName("id")] + public required uint Id { get; init; } + + [JsonPropertyName("samplesPerSecond")] + public required ushort SamplesPerSecond { get; init; } + + [JsonPropertyName("codec")] + public required string Codec { get; init; } + + [JsonPropertyName("samples")] + public required uint Samples { get; init; } +} + +public record AwcDetails +{ + [JsonPropertyName("streamCount")] + public required int StreamCount { get; init; } + + [JsonPropertyName("streams")] + public required IReadOnlyList Streams { get; init; } +} + +public record Gxt2EntryInfo +{ + [JsonPropertyName("hash")] + public required string Hash { get; init; } + + [JsonPropertyName("text")] + public required string Text { get; init; } +} + +public record Gxt2Details +{ + [JsonPropertyName("entryCount")] + public required int EntryCount { get; init; } + + [JsonPropertyName("entries")] + public required IReadOnlyList Entries { get; init; } +} diff --git a/CodeWalker.Cli/Json/SearchResult.cs b/CodeWalker.Cli/Json/SearchResult.cs new file mode 100644 index 000000000..006d14a4e --- /dev/null +++ b/CodeWalker.Cli/Json/SearchResult.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace CodeWalker.Cli.Json; + +public record SearchMatch +{ + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("size")] + public required long Size { get; init; } + + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("extension")] + public required string Extension { get; init; } + + [JsonPropertyName("nameHash")] + public required uint NameHash { get; init; } + + [JsonPropertyName("shortNameHash")] + public required uint ShortNameHash { get; init; } +} + +public record SearchResult : BaseResult +{ + [JsonPropertyName("rpfFile")] + public required string RpfFile { get; init; } + + [JsonPropertyName("pattern")] + public required string Pattern { get; init; } + + [JsonPropertyName("patternType")] + public required string PatternType { get; init; } + + [JsonPropertyName("matchCount")] + public required int MatchCount { get; init; } + + [JsonPropertyName("matches")] + public required IReadOnlyList Matches { get; init; } +} diff --git a/CodeWalker.Cli/Json/StatResult.cs b/CodeWalker.Cli/Json/StatResult.cs new file mode 100644 index 000000000..8baf3f247 --- /dev/null +++ b/CodeWalker.Cli/Json/StatResult.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace CodeWalker.Cli.Json; + +public record ExtensionStat +{ + [JsonPropertyName("extension")] + public required string Extension { get; init; } + + [JsonPropertyName("count")] + public required int Count { get; init; } + + [JsonPropertyName("totalSize")] + public required long TotalSize { get; init; } + + [JsonPropertyName("totalSizeFormatted")] + public required string TotalSizeFormatted { get; init; } + + [JsonPropertyName("avgSize")] + public required long AvgSize { get; init; } + + [JsonPropertyName("minSize")] + public required long MinSize { get; init; } + + [JsonPropertyName("maxSize")] + public required long MaxSize { get; init; } +} + +public record StatResult : BaseResult +{ + [JsonPropertyName("rpfFile")] + public required string RpfFile { get; init; } + + [JsonPropertyName("totalFiles")] + public required int TotalFiles { get; init; } + + [JsonPropertyName("totalSize")] + public required long TotalSize { get; init; } + + [JsonPropertyName("totalSizeFormatted")] + public required string TotalSizeFormatted { get; init; } + + [JsonPropertyName("resourceCount")] + public required int ResourceCount { get; init; } + + [JsonPropertyName("binaryCount")] + public required int BinaryCount { get; init; } + + [JsonPropertyName("compressedSize")] + public required long CompressedSize { get; init; } + + [JsonPropertyName("uncompressedSize")] + public required long UncompressedSize { get; init; } + + [JsonPropertyName("compressionRatio")] + public required double CompressionRatio { get; init; } + + [JsonPropertyName("extensions")] + public required IReadOnlyList Extensions { get; init; } +} diff --git a/CodeWalker.Cli/Json/ValidateResult.cs b/CodeWalker.Cli/Json/ValidateResult.cs new file mode 100644 index 000000000..c712a4c37 --- /dev/null +++ b/CodeWalker.Cli/Json/ValidateResult.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace CodeWalker.Cli.Json; + +public record ValidateFileEntry +{ + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("status")] + public required string Status { get; init; } + + [JsonPropertyName("message")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Message { get; init; } +} + +public record ValidateResult : BaseResult +{ + [JsonPropertyName("rpfFile")] + public required string RpfFile { get; init; } + + [JsonPropertyName("totalFiles")] + public required int TotalFiles { get; init; } + + [JsonPropertyName("valid")] + public required int Valid { get; init; } + + [JsonPropertyName("warnings")] + public required int Warnings { get; init; } + + [JsonPropertyName("errors")] + public required int Errors { get; init; } + + [JsonPropertyName("skipped")] + public required int Skipped { get; init; } + + [JsonPropertyName("files")] + public required IReadOnlyList Files { get; init; } +} diff --git a/CodeWalker.Cli/Program.cs b/CodeWalker.Cli/Program.cs index f4fc49fc1..8ebdbac48 100644 --- a/CodeWalker.Cli/Program.cs +++ b/CodeWalker.Cli/Program.cs @@ -11,6 +11,10 @@ PackHandler.CreateCommand(), DiffHandler.CreateCommand(), ExportHandler.CreateCommand(), + StatHandler.CreateCommand(), + SearchHandler.CreateCommand(), + ValidateHandler.CreateCommand(), + InspectHandler.CreateCommand(), }; return rootCommand.Parse(args).Invoke(); diff --git a/CodeWalker.Cli/SearchHandler.cs b/CodeWalker.Cli/SearchHandler.cs new file mode 100644 index 000000000..3d8632f4d --- /dev/null +++ b/CodeWalker.Cli/SearchHandler.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; +#if !NETCOREAPP +using CodeWalker.Cli.Polyfills; +#endif + + +namespace CodeWalker.Cli; + +public static class SearchHandler +{ + public static Command CreateCommand() + { + RpfCommandOptions rpfOpts = new(); + Argument patternArg = new("pattern") + { + Description = "Search pattern: glob, substring, or hash (0x hex or decimal)", + }; + + Command command = new("search", "Search for files by name, path, or hash in an RPF archive") + { + patternArg, + }; + rpfOpts.AddTo(command); + command.Aliases.Add("s"); + + command.SetAction(parseResult => + { + return Execute(rpfOpts.Parse(parseResult), parseResult.GetRequiredValue(patternArg)); + }); + + return command; + } + + public static int Execute(RpfOptions options, string pattern) + { + Json.SearchResult ErrorResult(string[] errorMessages) => + new() + { + Success = false, + RpfFile = options.RpfPath, + Pattern = pattern, + PatternType = "unknown", + MatchCount = 0, + Matches = [], + ErrorMessages = errorMessages, + }; + + string? initError = RpfService.ValidateAndLoadKeys( + options.RpfPath, + options.ExePath, + options.Gen9, + options.Json + ); + if (initError != null) + { + return RpfService.ReportError(initError, options.Json, ErrorResult([])); + } + + try + { + List scanErrors = []; + RpfFile rpf = RpfService.OpenRpf( + options.RpfPath, + options.Verbose, + options.Json, + scanErrors + ); + + if (!options.Json) + { + Console.Error.WriteLine(); + } + + // Collect all entries (including directories) recursively + List allEntries = []; + CollectAllEntries(rpf, options.Recursive, allEntries); + + // Detect pattern type + string patternType; + Func matcher; + + if (pattern.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + // Hex hash + patternType = "hash_hex"; + if ( + !uint.TryParse( + pattern.Substring(2), + System.Globalization.NumberStyles.HexNumber, + null, + out uint hash + ) + ) + { + return RpfService.ReportError( + $"Invalid hex hash: {pattern}", + options.Json, + ErrorResult([]) + ); + } + matcher = entry => entry.NameHash == hash || entry.ShortNameHash == hash; + } + else if ( + uint.TryParse(pattern, out uint decHash) + && pattern.Length >= 5 + && !HasGlobChars(pattern) + ) + { + // Decimal hash (require 5+ digits to avoid matching short filenames) + patternType = "hash_decimal"; + matcher = entry => entry.NameHash == decHash || entry.ShortNameHash == decHash; + } + else if (HasGlobChars(pattern)) + { + // Glob pattern — reuse Filter.Matches + patternType = "glob"; + string[] filters = Filter.Normalize([pattern]); + matcher = entry => entry.Path != null && Filter.Matches(entry.Path, filters); + } + else + { + // Substring match + patternType = "substring"; + string lowerPattern = pattern.ToLowerInvariant(); + matcher = entry => + entry.Path != null + && entry.Path.Replace('\\', '/').ToLowerInvariant().Contains(lowerPattern); + } + + // Match in parallel + Json.SearchMatch?[] results = new Json.SearchMatch?[allEntries.Count]; + + Parallel.For( + 0, + allEntries.Count, + new ParallelOptions { MaxDegreeOfParallelism = options.Threads }, + i => + { + RpfEntry entry = allEntries[i]; + if (!matcher(entry)) + return; + + long size = 0; + string type = "directory"; + string ext = ""; + + if (entry is RpfFileEntry fileEntry) + { + size = fileEntry.GetFileSize(); + type = RpfService.GetFileType(fileEntry); + ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); + } + + results[i] = new Json.SearchMatch + { + Path = entry.Path ?? entry.Name ?? "", + Name = entry.Name ?? "", + Size = size, + Type = type, + Extension = ext, + NameHash = entry.NameHash, + ShortNameHash = entry.ShortNameHash, + }; + } + ); + + // Collect non-null results + List matches = []; + foreach (Json.SearchMatch? match in results) + { + if (match != null) + matches.Add(match); + } + + Json.SearchResult result = new() + { + Success = scanErrors.Count == 0, + RpfFile = options.RpfPath, + Pattern = pattern, + PatternType = patternType, + MatchCount = matches.Count, + Matches = matches, + ErrorMessages = scanErrors.ToArray(), + }; + + if (options.Json) + { + Console.WriteLine( + JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + ); + } + else + { + foreach (Json.SearchMatch match in matches) + { + if (options.Verbose) + { + string sizeStr = options + .SizeFormat.ToFormattedString(match.Size) + .PadLeft(12); + Console.WriteLine($"{sizeStr} {match.Path}"); + } + else + { + Console.WriteLine(match.Path); + } + } + + Console.Error.WriteLine(); + Console.Error.WriteLine( + $"Found {matches.Count} matches for '{pattern}' ({patternType})" + ); + } + + return 0; + } + catch (Exception ex) + { + return RpfService.ReportError( + ex.Message, + options.Json, + ErrorResult([]), + options.Verbose ? ex.StackTrace : null + ); + } + } + + private static bool HasGlobChars(string s) + { + return s.Contains('*') || s.Contains('?') || s.Contains('['); + } + + private static void CollectAllEntries(RpfFile rpf, bool recursive, List entries) + { + if (rpf.AllEntries != null) + { + foreach (RpfEntry entry in rpf.AllEntries) + { + entries.Add(entry); + } + } + + if (recursive && rpf.Children != null) + { + foreach (RpfFile child in rpf.Children) + { + CollectAllEntries(child, recursive, entries); + } + } + } +} diff --git a/CodeWalker.Cli/StatHandler.cs b/CodeWalker.Cli/StatHandler.cs new file mode 100644 index 000000000..381c46b0d --- /dev/null +++ b/CodeWalker.Cli/StatHandler.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.IO; +using System.Linq; +using System.Text.Json; +using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; + +namespace CodeWalker.Cli; + +public static class StatHandler +{ + public static Command CreateCommand() + { + RpfCommandOptions rpfOpts = new(); + + Command command = new("stat", "Show aggregate statistics for RPF archive contents"); + rpfOpts.AddTo(command); + command.Aliases.Add("S"); + + command.SetAction(parseResult => + { + return Execute(rpfOpts.Parse(parseResult)); + }); + + return command; + } + + public static int Execute(RpfOptions options) + { + Json.StatResult ErrorResult(string[] errorMessages) => + new() + { + Success = false, + RpfFile = options.RpfPath, + TotalFiles = 0, + TotalSize = 0, + TotalSizeFormatted = "0 B", + ResourceCount = 0, + BinaryCount = 0, + CompressedSize = 0, + UncompressedSize = 0, + CompressionRatio = 0, + Extensions = [], + ErrorMessages = errorMessages, + }; + + string? initError = RpfService.ValidateAndLoadKeys( + options.RpfPath, + options.ExePath, + options.Gen9, + options.Json + ); + if (initError != null) + { + return RpfService.ReportError(initError, options.Json, ErrorResult([])); + } + + try + { + List scanErrors = []; + RpfFile rpf = RpfService.OpenRpf( + options.RpfPath, + options.Verbose, + options.Json, + scanErrors + ); + + if (!options.Json) + { + Console.Error.WriteLine(); + } + + List<(RpfFile rpf, RpfFileEntry entry)> entries = RpfService.CollectFiles( + rpf, + options.Filters, + options.Recursive + ); + + int resourceCount = 0; + int binaryCount = 0; + long compressedSize = 0; + long uncompressedSize = 0; + + Dictionary extStats = new(); + + foreach ((RpfFile _, RpfFileEntry fileEntry) in entries) + { + long size = fileEntry.GetFileSize(); + string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); + if (string.IsNullOrEmpty(ext)) + ext = "(none)"; + + if (extStats.TryGetValue(ext, out var stat)) + { + extStats[ext] = ( + stat.count + 1, + stat.total + size, + Math.Min(stat.min, size), + Math.Max(stat.max, size) + ); + } + else + { + extStats[ext] = (1, size, size, size); + } + + if (fileEntry is RpfResourceFileEntry rfe) + { + resourceCount++; + compressedSize += rfe.FileSize; + uncompressedSize += rfe.SystemSize + rfe.GraphicsSize; + } + else if (fileEntry is RpfBinaryFileEntry bfe) + { + binaryCount++; + compressedSize += bfe.FileSize; + uncompressedSize += bfe.FileUncompressedSize; + } + } + + long totalSize = entries.Sum(e => e.entry.GetFileSize()); + double compressionRatio = + uncompressedSize > 0 ? (double)compressedSize / uncompressedSize : 0; + + List extensionStats = extStats + .OrderByDescending(kv => kv.Value.total) + .Select(kv => new Json.ExtensionStat + { + Extension = kv.Key, + Count = kv.Value.count, + TotalSize = kv.Value.total, + TotalSizeFormatted = options.SizeFormat.ToFormattedString(kv.Value.total), + AvgSize = kv.Value.count > 0 ? kv.Value.total / kv.Value.count : 0, + MinSize = kv.Value.min, + MaxSize = kv.Value.max, + }) + .ToList(); + + Json.StatResult result = new() + { + Success = scanErrors.Count == 0, + RpfFile = options.RpfPath, + TotalFiles = entries.Count, + TotalSize = totalSize, + TotalSizeFormatted = options.SizeFormat.ToFormattedString(totalSize), + ResourceCount = resourceCount, + BinaryCount = binaryCount, + CompressedSize = compressedSize, + UncompressedSize = uncompressedSize, + CompressionRatio = Math.Round(compressionRatio, 4), + Extensions = extensionStats, + ErrorMessages = scanErrors.ToArray(), + }; + + if (options.Json) + { + Console.WriteLine( + JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + ); + } + else + { + // Table header + Console.WriteLine( + $"{"Extension", -12} {"Count", 8} {"Total", 14} {"Avg", 14} {"Min", 14} {"Max", 14}" + ); + Console.WriteLine(new string('-', 78)); + + foreach (Json.ExtensionStat ext in extensionStats) + { + Console.WriteLine( + $"{ext.Extension, -12} {ext.Count, 8} {options.SizeFormat.ToFormattedString(ext.TotalSize), 14} {options.SizeFormat.ToFormattedString(ext.AvgSize), 14} {options.SizeFormat.ToFormattedString(ext.MinSize), 14} {options.SizeFormat.ToFormattedString(ext.MaxSize), 14}" + ); + } + + Console.Error.WriteLine(); + Console.Error.WriteLine( + $"Total: {entries.Count} files, {options.SizeFormat.ToFormattedString(totalSize)}" + ); + Console.Error.WriteLine($"Types: {resourceCount} resource, {binaryCount} binary"); + + if (uncompressedSize > 0) + { + Console.Error.WriteLine( + $"Compression: {options.SizeFormat.ToFormattedString(compressedSize)} / {options.SizeFormat.ToFormattedString(uncompressedSize)} ({compressionRatio:P1})" + ); + } + } + + return 0; + } + catch (Exception ex) + { + return RpfService.ReportError( + ex.Message, + options.Json, + ErrorResult([]), + options.Verbose ? ex.StackTrace : null + ); + } + } +} diff --git a/CodeWalker.Cli/ValidateHandler.cs b/CodeWalker.Cli/ValidateHandler.cs new file mode 100644 index 000000000..a905b9c46 --- /dev/null +++ b/CodeWalker.Cli/ValidateHandler.cs @@ -0,0 +1,340 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; + +namespace CodeWalker.Cli; + +public record ValidateOptions +{ + public required RpfOptions Rpf { get; init; } + public required bool Progress { get; init; } +} + +public static class ValidateHandler +{ + public static Command CreateCommand() + { + RpfCommandOptions rpfOpts = new(); + // csharpier-ignore-start + Option progressOption = new("--progress", "-P") + { + Description = "Show progress bar during validation", + }; + // csharpier-ignore-end + + Command command = new("validate", "Validate game file integrity by parsing RPF contents") + { + progressOption, + }; + rpfOpts.AddTo(command); + command.Aliases.Add("val"); + + command.SetAction(parseResult => + { + ValidateOptions options = new() + { + Rpf = rpfOpts.Parse(parseResult), + Progress = parseResult.GetValue(progressOption), + }; + return Execute(options); + }); + + return command; + } + + public static int Execute(ValidateOptions options) + { + Json.ValidateResult ErrorResult(string[] errorMessages) => + new() + { + Success = false, + RpfFile = options.Rpf.RpfPath, + TotalFiles = 0, + Valid = 0, + Warnings = 0, + Errors = 0, + Skipped = 0, + Files = [], + ErrorMessages = errorMessages, + }; + + string? initError = RpfService.ValidateAndLoadKeys( + options.Rpf.RpfPath, + options.Rpf.ExePath, + options.Rpf.Gen9, + options.Rpf.Json + ); + if (initError != null) + { + return RpfService.ReportError(initError, options.Rpf.Json, ErrorResult([])); + } + + try + { + List scanErrors = []; + RpfFile rpf = RpfService.OpenRpf( + options.Rpf.RpfPath, + options.Rpf.Verbose, + options.Rpf.Json, + scanErrors + ); + + if (!options.Rpf.Json) + { + Console.Error.WriteLine(); + } + + List<(RpfFile rpf, RpfFileEntry entry)> entries = RpfService.CollectFiles( + rpf, + options.Rpf.Filters, + options.Rpf.Recursive + ); + + Json.ValidateFileEntry?[] results = new Json.ValidateFileEntry?[entries.Count]; + object consoleLock = new(); + + using (ProgressBar progress = new(entries.Count, options.Progress && !options.Rpf.Json)) + { + Parallel.For( + 0, + entries.Count, + new ParallelOptions { MaxDegreeOfParallelism = options.Rpf.Threads }, + i => + { + (RpfFile sourceRpf, RpfFileEntry fileEntry) = entries[i]; + string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); + + try + { + (string status, string? message) = ValidateFile( + sourceRpf, + fileEntry, + ext + ); + + results[i] = new Json.ValidateFileEntry + { + Path = fileEntry.Path, + Name = fileEntry.Name, + Status = status, + Message = message, + }; + + if ( + !options.Rpf.Json + && !options.Progress + && (status == "warning" || status == "error") + ) + { + lock (consoleLock) + { + Console.Error.WriteLine( + $"[{status.ToUpperInvariant()}] {fileEntry.Path}: {message}" + ); + } + } + } + catch (Exception ex) + { + results[i] = new Json.ValidateFileEntry + { + Path = fileEntry.Path, + Name = fileEntry.Name, + Status = "error", + Message = ex.Message, + }; + + if (!options.Rpf.Json && !options.Progress) + { + lock (consoleLock) + { + Console.Error.WriteLine( + $"[ERROR] {fileEntry.Path}: {ex.Message}" + ); + } + } + } + + progress.Increment(fileEntry.Path); + } + ); + } + + // Aggregate results + int valid = 0; + int warnings = 0; + int errors = 0; + int skipped = 0; + List files = []; + + foreach (Json.ValidateFileEntry? entry in results) + { + if (entry == null) + continue; + + switch (entry.Status) + { + case "valid": + valid++; + break; + case "warning": + warnings++; + break; + case "error": + errors++; + break; + case "skipped": + skipped++; + break; + } + + // In verbose mode or JSON, include all; otherwise only warnings/errors + if (options.Rpf.Json || options.Rpf.Verbose || entry.Status is "warning" or "error") + { + files.Add(entry); + } + } + + Json.ValidateResult result = new() + { + Success = errors == 0 && scanErrors.Count == 0, + RpfFile = options.Rpf.RpfPath, + TotalFiles = entries.Count, + Valid = valid, + Warnings = warnings, + Errors = errors, + Skipped = skipped, + Files = files, + ErrorMessages = scanErrors.ToArray(), + }; + + if (options.Rpf.Json) + { + Console.WriteLine( + JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + ); + } + else + { + Console.Error.WriteLine(); + Console.Error.WriteLine( + $"Validation complete: {valid} valid, {warnings} warnings, {errors} errors, {skipped} skipped" + ); + } + + return errors > 0 ? 1 : 0; + } + catch (Exception ex) + { + return RpfService.ReportError( + ex.Message, + options.Rpf.Json, + ErrorResult([]), + options.Rpf.Verbose ? ex.StackTrace : null + ); + } + } + + private static (string status, string? message) ValidateFile( + RpfFile sourceRpf, + RpfFileEntry fileEntry, + string ext + ) + { + switch (ext) + { + case ".ytd": + { + YtdFile file = RpfFile.GetFile(fileEntry); + if (file == null) + return ("error", "Failed to load YTD file"); + if ( + file.TextureDict?.Textures?.data_items == null + || file.TextureDict.Textures.data_items.Length == 0 + ) + return ("warning", "Texture dictionary is empty"); + return ("valid", null); + } + case ".ydr": + { + YdrFile file = RpfFile.GetFile(fileEntry); + if (file == null) + return ("error", "Failed to load YDR file"); + if (file.Drawable == null) + return ("error", "Drawable is null"); + return ("valid", null); + } + case ".ydd": + { + YddFile file = RpfFile.GetFile(fileEntry); + if (file == null) + return ("error", "Failed to load YDD file"); + if (file.DrawableDict == null) + return ("error", "DrawableDict is null"); + return ("valid", null); + } + case ".yft": + { + YftFile file = RpfFile.GetFile(fileEntry); + if (file == null) + return ("error", "Failed to load YFT file"); + if (file.Fragment == null) + return ("error", "Fragment is null"); + return ("valid", null); + } + case ".ymap": + { + YmapFile file = RpfFile.GetFile(fileEntry); + if (file == null) + return ("error", "Failed to load YMAP file"); + if (file.AllEntities == null || file.AllEntities.Length == 0) + return ("warning", "No entities found"); + return ("valid", null); + } + case ".ytyp": + { + YtypFile file = RpfFile.GetFile(fileEntry); + if (file == null) + return ("error", "Failed to load YTYP file"); + if (file.AllArchetypes == null || file.AllArchetypes.Length == 0) + return ("warning", "No archetypes found"); + return ("valid", null); + } + case ".ybn": + { + YbnFile file = RpfFile.GetFile(fileEntry); + if (file == null) + return ("error", "Failed to load YBN file"); + if (file.Bounds == null) + return ("error", "Bounds is null"); + return ("valid", null); + } + case ".awc": + { + AwcFile file = RpfFile.GetFile(fileEntry); + if (file == null) + return ("error", "Failed to load AWC file"); + if (file.Streams == null || file.Streams.Length == 0) + return ("warning", "No audio streams found"); + return ("valid", null); + } + case ".gxt2": + { + Gxt2File file = RpfFile.GetFile(fileEntry); + if (file == null) + return ("error", "Failed to load GXT2 file"); + if (file.TextEntries == null || file.TextEntries.Length == 0) + return ("warning", "No text entries found"); + return ("valid", null); + } + default: + return ("skipped", null); + } + } +} From a8f3a028103ce01292a73f462ab8aa742790041b Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:26:32 +0200 Subject: [PATCH 08/45] refactor(cli): turn the analyzers up and clear what they flag The project had the analyzer packages referenced but AnalysisLevel at 'latest', which leaves most of the rules off, and no .editorconfig of its own, so it inherited whatever the solution root happened to say. This project now carries its own .editorconfig: latest-recommended, EnforceCodeStyleInBuild so the style rules fail the build rather than decorating the editor, and the naming, ordering and expression-style rules raised from suggestion to warning. CA1308 is the one that goes the other way, since lowercasing entry names is exactly what the RPF format wants. Clearing what that flagged is the rest of the diff: records sealed, accessibility modifiers spelled out, collection and object initializers, null propagation, compound assignment, and documentation comments generated so CS1591 has something to check. --- CodeWalker.Cli/.editorconfig | 446 ++++++++++++++++++++++++ CodeWalker.Cli/CodeWalker.Cli.csproj | 8 +- CodeWalker.Cli/CommonOptions.cs | 7 +- CodeWalker.Cli/Compiler.cs | 65 +++- CodeWalker.Cli/DiffHandler.cs | 19 +- CodeWalker.Cli/ExportAudioHandler.cs | 3 +- CodeWalker.Cli/ExportHandler.cs | 9 +- CodeWalker.Cli/ExportService.cs | 10 +- CodeWalker.Cli/ExportTextHandler.cs | 3 +- CodeWalker.Cli/ExportTexturesHandler.cs | 3 +- CodeWalker.Cli/ExportXmlHandler.cs | 3 +- CodeWalker.Cli/ExtractHandler.cs | 17 +- CodeWalker.Cli/Gen9Handler.cs | 20 +- CodeWalker.Cli/HashHandler.cs | 14 +- CodeWalker.Cli/Helpers/Filter.cs | 35 +- CodeWalker.Cli/Helpers/ProgressBar.cs | 7 +- CodeWalker.Cli/Helpers/SizeFormat.cs | 8 +- CodeWalker.Cli/InspectHandler.cs | 51 ++- CodeWalker.Cli/Json/DiffResult.cs | 6 +- CodeWalker.Cli/Json/ExportResult.cs | 4 +- CodeWalker.Cli/Json/ExtractResult.cs | 2 +- CodeWalker.Cli/Json/FileEntry.cs | 2 +- CodeWalker.Cli/Json/Gen9Result.cs | 4 +- CodeWalker.Cli/Json/HashResult.cs | 4 +- CodeWalker.Cli/Json/InspectResult.cs | 32 +- CodeWalker.Cli/Json/ListResult.cs | 2 +- CodeWalker.Cli/Json/PackResult.cs | 3 +- CodeWalker.Cli/Json/SearchResult.cs | 4 +- CodeWalker.Cli/Json/StatResult.cs | 4 +- CodeWalker.Cli/Json/TreeResult.cs | 4 +- CodeWalker.Cli/Json/ValidateResult.cs | 4 +- CodeWalker.Cli/ListHandler.cs | 12 +- CodeWalker.Cli/PackHandler.cs | 9 +- CodeWalker.Cli/Program.cs | 1 + CodeWalker.Cli/RpfOptions.cs | 7 +- CodeWalker.Cli/RpfService.cs | 9 +- CodeWalker.Cli/SearchHandler.cs | 24 +- CodeWalker.Cli/StatHandler.cs | 42 +-- CodeWalker.Cli/TreeHandler.cs | 13 +- CodeWalker.Cli/ValidateHandler.cs | 162 +++++---- 40 files changed, 780 insertions(+), 302 deletions(-) create mode 100644 CodeWalker.Cli/.editorconfig diff --git a/CodeWalker.Cli/.editorconfig b/CodeWalker.Cli/.editorconfig new file mode 100644 index 000000000..77477c8bc --- /dev/null +++ b/CodeWalker.Cli/.editorconfig @@ -0,0 +1,446 @@ +root = true + +# All files +[*] +indent_style = space + +# Xml files +[*.xml] +indent_size = 2 + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = true + +#### .NET Coding Conventions #### +[*.{cs,vb}] + +# Organize usings +dotnet_separate_import_directive_groups = true +dotnet_sort_system_directives_first = true +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning + +# Expression-level preferences +dotnet_style_coalesce_expression = true:warning +dotnet_style_collection_initializer = true:warning +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_style_null_propagation = true:warning +dotnet_style_object_initializer = true:warning +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +dotnet_style_prefer_compound_assignment = true:warning +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:warning + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:warning + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +#### C# Coding Conventions #### +[*.cs] + +# var preferences +csharp_style_var_elsewhere = false:suggestion +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = false:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:suggestion +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:warning +csharp_style_pattern_matching_over_is_with_cast_check = true:warning +csharp_style_prefer_extended_property_pattern = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:warning + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_anonymous_function = true:suggestion +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public,private,protected,internal,file,const,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion + +# Code-block preferences +csharp_prefer_braces = true:suggestion +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_namespace_declarations = file_scoped:warning +csharp_style_prefer_method_group_conversion = true:suggestion +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_top_level_statements = true:silent + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_inlined_variable_declaration = true:warning +csharp_style_prefer_index_operator = true:warning +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:warning +csharp_style_prefer_range_operator = true:warning +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### .NET Code Quality Rules (CA) #### +[*.cs] + +# Bulk category severities +dotnet_analyzer_diagnostic.category-Design.severity = warning +dotnet_analyzer_diagnostic.category-Maintainability.severity = warning +dotnet_analyzer_diagnostic.category-Naming.severity = warning +dotnet_analyzer_diagnostic.category-Performance.severity = warning +dotnet_analyzer_diagnostic.category-Reliability.severity = warning +dotnet_analyzer_diagnostic.category-Security.severity = warning +dotnet_analyzer_diagnostic.category-Usage.severity = warning + +# Noisy or inapplicable — suppress/downgrade +dotnet_diagnostic.CA1031.severity = suggestion # Do not catch general exception types (intentional in CLI) +dotnet_diagnostic.CA1303.severity = suggestion # Don't pass literals as localized params (no i18n needed) +dotnet_diagnostic.CA1308.severity = suggestion # Normalize strings to uppercase (ToLowerInvariant is fine) + +#### IDE Code Style Rules #### +[*.cs] + +dotnet_diagnostic.IDE0005.severity = warning # Remove unnecessary using directives +dotnet_diagnostic.IDE0051.severity = warning # Remove unused private members +dotnet_diagnostic.IDE0052.severity = warning # Remove unread private members +dotnet_diagnostic.IDE0060.severity = warning # Remove unused parameter +dotnet_diagnostic.IDE0130.severity = suggestion # Namespace does not match folder structure +dotnet_diagnostic.IDE0290.severity = suggestion # Use primary constructors + +#### Roslynator Rules (RCS) #### +[*.cs] + +# Code quality — elevate to warning +dotnet_diagnostic.RCS1015.severity = warning # Use nameof operator +dotnet_diagnostic.RCS1049.severity = warning # Simplify boolean comparison +dotnet_diagnostic.RCS1058.severity = warning # Use compound assignment +dotnet_diagnostic.RCS1068.severity = warning # Simplify logical negation +dotnet_diagnostic.RCS1077.severity = warning # Optimize LINQ method call +dotnet_diagnostic.RCS1097.severity = warning # Remove redundant ToString call +dotnet_diagnostic.RCS1113.severity = warning # Use string.IsNullOrEmpty +dotnet_diagnostic.RCS1128.severity = warning # Use coalesce expression +dotnet_diagnostic.RCS1146.severity = warning # Use conditional access +dotnet_diagnostic.RCS1151.severity = warning # Remove redundant cast +dotnet_diagnostic.RCS1155.severity = warning # Use StringComparison when comparing strings +dotnet_diagnostic.RCS1163.severity = warning # Unused parameter +dotnet_diagnostic.RCS1169.severity = warning # Make field read-only +dotnet_diagnostic.RCS1187.severity = warning # Use constant instead of field +dotnet_diagnostic.RCS1197.severity = warning # Optimize StringBuilder.Append call +dotnet_diagnostic.RCS1199.severity = warning # Unnecessary null check +dotnet_diagnostic.RCS1202.severity = warning # Avoid NullReferenceException +dotnet_diagnostic.RCS1213.severity = warning # Remove unused member declaration +dotnet_diagnostic.RCS1225.severity = warning # Make class sealed +dotnet_diagnostic.RCS1227.severity = warning # Validate arguments correctly +dotnet_diagnostic.RCS1233.severity = warning # Use short-circuiting operator +dotnet_diagnostic.RCS1235.severity = warning # Optimize method call +dotnet_diagnostic.RCS1246.severity = warning # Use element access + +# Simplification — elevate to suggestion +dotnet_diagnostic.RCS1033.severity = suggestion # Remove redundant boolean literal +dotnet_diagnostic.RCS1084.severity = suggestion # Use coalesce expression instead of conditional +dotnet_diagnostic.RCS1104.severity = suggestion # Simplify conditional expression +dotnet_diagnostic.RCS1105.severity = suggestion # Unnecessary interpolation +dotnet_diagnostic.RCS1143.severity = suggestion # Simplify coalesce expression +dotnet_diagnostic.RCS1179.severity = suggestion # Unnecessary assignment +dotnet_diagnostic.RCS1192.severity = suggestion # Unnecessary verbatim string literal +dotnet_diagnostic.RCS1196.severity = suggestion # Call extension method as instance method +dotnet_diagnostic.RCS1218.severity = suggestion # Simplify code branching +dotnet_diagnostic.RCS1249.severity = suggestion # Unnecessary null-forgiving operator + +#### Naming styles #### +[*.{cs,vb}] + +# Naming rules + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +# Symbol specifications + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.type_parameters.applicable_kinds = namespace +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case + diff --git a/CodeWalker.Cli/CodeWalker.Cli.csproj b/CodeWalker.Cli/CodeWalker.Cli.csproj index 0f27d7161..c6bd4c60b 100644 --- a/CodeWalker.Cli/CodeWalker.Cli.csproj +++ b/CodeWalker.Cli/CodeWalker.Cli.csproj @@ -20,7 +20,7 @@ /> @@ -30,15 +30,17 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive + latest-all true - latest + true + true diff --git a/CodeWalker.Cli/CommonOptions.cs b/CodeWalker.Cli/CommonOptions.cs index 1976743cd..56454fa68 100644 --- a/CodeWalker.Cli/CommonOptions.cs +++ b/CodeWalker.Cli/CommonOptions.cs @@ -1,11 +1,12 @@ using System; using System.CommandLine; using System.IO; + using CodeWalker.Cli.Helpers; namespace CodeWalker.Cli; -public record CommonOptions +internal sealed record CommonOptions { public required string ExePath { get; init; } public required bool Verbose { get; init; } @@ -19,9 +20,8 @@ public record CommonOptions /// Create an instance, call to register options on a command, /// then call inside the action to build a . /// -public sealed class CommonCommandOptions +internal sealed class CommonCommandOptions { - // csharpier-ignore-start public Option Exe { get; } = new("--exe", "-e") { Description = "Path to the GTA V installation directory (containing GTA5.exe)", @@ -48,7 +48,6 @@ public sealed class CommonCommandOptions Description = "Number of threads for parallel processing", DefaultValueFactory = _ => Environment.ProcessorCount, }; - // csharpier-ignore-end public CommonCommandOptions() { diff --git a/CodeWalker.Cli/Compiler.cs b/CodeWalker.Cli/Compiler.cs index 8ed39db31..447cad949 100644 --- a/CodeWalker.Cli/Compiler.cs +++ b/CodeWalker.Cli/Compiler.cs @@ -2,6 +2,13 @@ // Posted by Matthew Watson // Retrieved 2026-02-06, License - CC BY-SA 4.0 +#pragma warning disable IDE0130 // Namespace does not match folder structure + +#if !NETCOREAPP +using System; +using System.Text; +#endif + #if !NET5_0_OR_GREATER using System.ComponentModel; #endif @@ -28,14 +35,9 @@ internal static class IsExternalInit { } internal sealed class RequiredMemberAttribute : Attribute { } [AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)] - internal sealed class CompilerFeatureRequiredAttribute : Attribute + internal sealed class CompilerFeatureRequiredAttribute(string featureName) : Attribute { - public CompilerFeatureRequiredAttribute(string featureName) - { - FeatureName = featureName; - } - - public string FeatureName { get; } + public string FeatureName { get; } = featureName; public bool IsOptional { get; init; } public const string RefStructs = nameof(RefStructs); @@ -53,15 +55,60 @@ internal sealed class SetsRequiredMembersAttribute : Attribute { } #endif } -#if !NETCOREAPP namespace CodeWalker.Cli.Polyfills { +#if !NETCOREAPP internal static class StringExtensions { public static bool Contains(this string s, char value) { return s.IndexOf(value) >= 0; } + + public static bool Contains(this string s, char value, StringComparison comparisonType) + { + return s.IndexOf(value.ToString(), comparisonType) >= 0; + } + + public static bool Contains(this string s, string value, StringComparison comparisonType) + { + return s.IndexOf(value, comparisonType) >= 0; + } + + public static bool StartsWith(this string s, char value) + { + return s.Length > 0 && s[0] == value; + } + + private static string ReplaceInternal( + this string s, + string oldValue, + string? newValue, + StringComparison comparisonType + ) + { + StringBuilder? sb = new(); + int start = 0; + int index; + while ((index = s.IndexOf(oldValue, start, comparisonType)) >= 0) + { + sb.Append(s, start, index - start); + if (newValue != null) + sb.Append(newValue); + start = index + oldValue.Length; + } + sb.Append(s, start, s.Length - start); + return sb.ToString(); + } + + public static string Replace(this string s, string oldValue, string? newValue, StringComparison comparisonType) => + comparisonType switch + { + StringComparison.Ordinal => s.Replace(oldValue, newValue), + _ => s.ReplaceInternal(oldValue, newValue, comparisonType), + }; } -} #endif +} + +#pragma warning restore IDE0130 // Namespace does not match folder structure diff --git a/CodeWalker.Cli/DiffHandler.cs b/CodeWalker.Cli/DiffHandler.cs index 4d19b4fec..9914efe86 100644 --- a/CodeWalker.Cli/DiffHandler.cs +++ b/CodeWalker.Cli/DiffHandler.cs @@ -4,12 +4,13 @@ using System.IO; using System.Text.Json; using System.Threading.Tasks; + using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; namespace CodeWalker.Cli; -public record DiffOptions +internal sealed record DiffOptions { public required string LeftPath { get; init; } public required string RightPath { get; init; } @@ -18,12 +19,11 @@ public record DiffOptions public required bool Recursive { get; init; } } -public static class DiffHandler +internal static class DiffHandler { public static Command CreateCommand() { CommonCommandOptions commonOpts = new(); - // csharpier-ignore-start Option leftOption = new("--left", "-l") { Description = "First RPF archive to compare", @@ -45,7 +45,6 @@ public static Command CreateCommand() { Description = "Include nested RPFs in comparison", }; - // csharpier-ignore-end Command command = new("diff", "Compare two RPF archives") { @@ -174,7 +173,7 @@ Json.DiffResult ErrorResult(string[] errorMessages) => if (rightDict.ContainsKey(path)) paths.Add(path); } - commonPaths = paths.ToArray(); + commonPaths = [.. paths]; } // Result per common path: null = unchanged, non-null = modified entry @@ -308,12 +307,12 @@ Json.DiffResult ErrorResult(string[] errorMessages) => Success = true, LeftRpf = options.LeftPath, RightRpf = options.RightPath, - Added = added.ToArray(), - Removed = removed.ToArray(), - Modified = modified.ToArray(), - Unchanged = unchanged.ToArray(), + Added = [.. added], + Removed = [.. removed], + Modified = [.. modified], + Unchanged = [.. unchanged], Summary = summary, - ErrorMessages = errorMessages.ToArray(), + ErrorMessages = [.. errorMessages], }; if (options.Common.Json) diff --git a/CodeWalker.Cli/ExportAudioHandler.cs b/CodeWalker.Cli/ExportAudioHandler.cs index dc7299cd9..e418fcc0c 100644 --- a/CodeWalker.Cli/ExportAudioHandler.cs +++ b/CodeWalker.Cli/ExportAudioHandler.cs @@ -1,10 +1,11 @@ using System.CommandLine; using System.IO; + using CodeWalker.GameFiles; namespace CodeWalker.Cli; -public static class ExportAudioHandler +internal static class ExportAudioHandler { private static readonly string[] DefaultFilters = ["*.awc"]; diff --git a/CodeWalker.Cli/ExportHandler.cs b/CodeWalker.Cli/ExportHandler.cs index 6e2882d44..7da6d6673 100644 --- a/CodeWalker.Cli/ExportHandler.cs +++ b/CodeWalker.Cli/ExportHandler.cs @@ -1,10 +1,9 @@ -using System; using System.CommandLine; using System.IO; namespace CodeWalker.Cli; -public record ExportOptions +internal sealed record ExportOptions { public required RpfOptions Rpf { get; init; } public required string OutputPath { get; init; } @@ -13,11 +12,10 @@ public record ExportOptions public required bool Progress { get; init; } } -public sealed class ExportCommandOptions +internal sealed class ExportCommandOptions { private readonly RpfCommandOptions _rpfOpts = new(); - // csharpier-ignore-start public Option Output { get; } = new("--output", "-o") { Description = "Output directory", @@ -38,7 +36,6 @@ public sealed class ExportCommandOptions { Description = "Show progress bar during export", }; - // csharpier-ignore-end public void AddTo(Command command) { @@ -62,7 +59,7 @@ public ExportOptions Parse(ParseResult parseResult) } } -public static class ExportHandler +internal static class ExportHandler { public static Command CreateCommand() { diff --git a/CodeWalker.Cli/ExportService.cs b/CodeWalker.Cli/ExportService.cs index 918cf7cd0..d196911ce 100644 --- a/CodeWalker.Cli/ExportService.cs +++ b/CodeWalker.Cli/ExportService.cs @@ -3,6 +3,7 @@ using System.IO; using System.Text.Json; using System.Threading.Tasks; + using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; @@ -16,14 +17,15 @@ namespace CodeWalker.Cli; /// The RPF file entry to process. /// The raw file data extracted from the RPF. /// The output directory for this file (includes relative path). -public delegate (Json.ExportFileEntry? entry, string? error) ExportFileProcessor( +/// When true, skip files that already exist at the output path. +internal delegate (Json.ExportFileEntry? entry, string? error) ExportFileProcessor( RpfFileEntry fileEntry, byte[] data, string fileOutputDir, bool noOverwrite ); -public static class ExportService +internal static class ExportService { public static int Execute( ExportOptions options, @@ -248,8 +250,8 @@ Json.ExportResult ErrorResult(string[] errorMessages) => Skipped = skipped, Errors = errors, DryRun = options.DryRun, - Files = files.ToArray(), - ErrorMessages = errorMessages.ToArray(), + Files = [.. files], + ErrorMessages = [.. errorMessages], }; if (options.Rpf.Json) diff --git a/CodeWalker.Cli/ExportTextHandler.cs b/CodeWalker.Cli/ExportTextHandler.cs index 5d6d6bd7d..66373c851 100644 --- a/CodeWalker.Cli/ExportTextHandler.cs +++ b/CodeWalker.Cli/ExportTextHandler.cs @@ -1,11 +1,12 @@ using System.CommandLine; using System.IO; using System.Text; + using CodeWalker.GameFiles; namespace CodeWalker.Cli; -public static class ExportTextHandler +internal static class ExportTextHandler { private static readonly string[] DefaultFilters = ["*.gxt2"]; diff --git a/CodeWalker.Cli/ExportTexturesHandler.cs b/CodeWalker.Cli/ExportTexturesHandler.cs index 1968b5dc0..1f8e58541 100644 --- a/CodeWalker.Cli/ExportTexturesHandler.cs +++ b/CodeWalker.Cli/ExportTexturesHandler.cs @@ -1,11 +1,12 @@ using System.CommandLine; using System.IO; + using CodeWalker.GameFiles; using CodeWalker.Utils; namespace CodeWalker.Cli; -public static class ExportTexturesHandler +internal static class ExportTexturesHandler { private static readonly string[] DefaultFilters = ["*.ytd"]; diff --git a/CodeWalker.Cli/ExportXmlHandler.cs b/CodeWalker.Cli/ExportXmlHandler.cs index ac14f59ae..0daac2b00 100644 --- a/CodeWalker.Cli/ExportXmlHandler.cs +++ b/CodeWalker.Cli/ExportXmlHandler.cs @@ -1,11 +1,12 @@ using System.CommandLine; using System.IO; using System.Text; + using CodeWalker.GameFiles; namespace CodeWalker.Cli; -public static class ExportXmlHandler +internal static class ExportXmlHandler { public static Command CreateCommand() { diff --git a/CodeWalker.Cli/ExtractHandler.cs b/CodeWalker.Cli/ExtractHandler.cs index 947690ee1..754116e86 100644 --- a/CodeWalker.Cli/ExtractHandler.cs +++ b/CodeWalker.Cli/ExtractHandler.cs @@ -5,12 +5,17 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; + using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; +#if !NETCOREAPP +using CodeWalker.Cli.Polyfills; +#endif + namespace CodeWalker.Cli; -public record ExtractOptions +internal sealed record ExtractOptions { public required RpfOptions Rpf { get; init; } public required string? OutputPath { get; init; } @@ -19,12 +24,11 @@ public record ExtractOptions public required bool Progress { get; init; } } -public static class ExtractHandler +internal static class ExtractHandler { public static Command CreateCommand() { RpfCommandOptions rpfOpts = new(); - // csharpier-ignore-start Option outputOption = new("--output", "-o") { Description = "Output directory", @@ -45,7 +49,6 @@ public static Command CreateCommand() { Description = "Show progress bar during extraction", }; - // csharpier-ignore-end Command command = new("extract", "Extract files from an RPF archive") { @@ -165,7 +168,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => string relativePath = fileEntry.Path; string outputPath = Path.Combine( outputDir, - relativePath.Replace("\\", Path.DirectorySeparatorChar.ToString()) + relativePath.Replace("\\", Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) ); string? fileDir = Path.GetDirectoryName(outputPath); @@ -311,8 +314,8 @@ jsonEntry with Skipped = skipped, Errors = errors, DryRun = options.DryRun, - Files = files.ToArray(), - ErrorMessages = errorMessages.ToArray(), + Files = [.. files], + ErrorMessages = [.. errorMessages], }; if (options.Rpf.Json) diff --git a/CodeWalker.Cli/Gen9Handler.cs b/CodeWalker.Cli/Gen9Handler.cs index 1af309160..c6567516e 100644 --- a/CodeWalker.Cli/Gen9Handler.cs +++ b/CodeWalker.Cli/Gen9Handler.cs @@ -3,15 +3,15 @@ using System.CommandLine; using System.IO; using System.Text.Json; -using System.Threading; using System.Threading.Tasks; + using CodeWalker.Cli.Helpers; using CodeWalker.Core.Utils; using CodeWalker.GameFiles; namespace CodeWalker.Cli; -public record Gen9Options +internal sealed record Gen9Options { public required string InputPath { get; init; } public required string OutputPath { get; init; } @@ -22,12 +22,11 @@ public record Gen9Options public required bool Progress { get; init; } } -public static class Gen9Handler +internal static class Gen9Handler { public static Command CreateCommand() { CommonCommandOptions commonOpts = new(); - // csharpier-ignore-start Option inputOption = new("--input", "-i") { Description = "Input folder containing files to convert", @@ -59,7 +58,6 @@ public static Command CreateCommand() { Description = "Show progress bar", }; - // csharpier-ignore-end Command command = new("gen9", "Convert files between standard and enhanced (Gen9) formats") { @@ -155,7 +153,7 @@ Json.Gen9Result ErrorResult(string[] errorMessages) => } string inputFolder = options.InputPath; - if (!inputFolder.EndsWith(Path.DirectorySeparatorChar.ToString())) + if (!inputFolder.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)) { inputFolder += Path.DirectorySeparatorChar; } @@ -219,7 +217,7 @@ Json.Gen9Result ErrorResult(string[] errorMessages) => i => { string path = filePaths[i]; - string relPath = path.Substring(inputFolder.Length); + string relPath = path[inputFolder.Length..]; string outPath = Path.Combine(options.OutputPath, relPath); try @@ -362,7 +360,7 @@ out bool wasConverted // Process RPF files sequentially (unsafe to parallelize) foreach (string path in rpfPaths) { - string relPath = path.Substring(inputFolder.Length); + string relPath = path[inputFolder.Length..]; string outPath = Path.Combine(options.OutputPath, relPath); try @@ -400,7 +398,6 @@ out bool wasConverted files, errorMessages, ref converted, - ref skipped, ref errors ); @@ -438,8 +435,8 @@ ref errors Skipped = skipped, Copied = copied, Errors = errors, - Files = files.ToArray(), - ErrorMessages = errorMessages.ToArray(), + Files = [.. files], + ErrorMessages = [.. errorMessages], }; if (options.Common.Json) @@ -482,7 +479,6 @@ private static void ProcessRpfFile( List files, List errorMessages, ref int converted, - ref int skipped, ref int errors ) { diff --git a/CodeWalker.Cli/HashHandler.cs b/CodeWalker.Cli/HashHandler.cs index b9c6d1cb9..1216c0bdc 100644 --- a/CodeWalker.Cli/HashHandler.cs +++ b/CodeWalker.Cli/HashHandler.cs @@ -2,11 +2,12 @@ using System.Collections.Generic; using System.CommandLine; using System.Text.Json; + using CodeWalker.GameFiles; namespace CodeWalker.Cli; -public record HashOptions +internal sealed record HashOptions { public required string[] Inputs { get; init; } public required string Encoding { get; init; } @@ -16,11 +17,10 @@ public record HashOptions public const JenkHashInputEncoding DefaultJenkHashEncoding = JenkHashInputEncoding.UTF8; } -public static class HashHandler +internal static class HashHandler { public static Command CreateCommand() { - // csharpier-ignore-start Option inputOption = new("--input", "-i") { Description = "Text string(s) to hash", @@ -34,10 +34,10 @@ public static Command CreateCommand() DefaultValueFactory = _ => HashOptions.DefaultEncoding, }; - Option jsonOption = new("--json") { + Option jsonOption = new("--json") + { Description = "Output results in JSON format", }; - // csharpier-ignore-end Command command = new("hash", "Generate Jenkins hashes for GTA V game identifiers") { @@ -63,7 +63,7 @@ public static Command CreateCommand() public static int Execute(HashOptions options) { - Json.HashResult ErrorResult(string[] errorMessages) => + static Json.HashResult ErrorResult(string[] errorMessages) => new() { Success = false, @@ -125,7 +125,7 @@ Json.HashResult ErrorResult(string[] errorMessages) => Json.HashResult result = new() { Success = true, - Hashes = hashes.ToArray(), + Hashes = [.. hashes], ErrorMessages = [], }; Console.WriteLine( diff --git a/CodeWalker.Cli/Helpers/Filter.cs b/CodeWalker.Cli/Helpers/Filter.cs index 709bd4389..b8f962d34 100644 --- a/CodeWalker.Cli/Helpers/Filter.cs +++ b/CodeWalker.Cli/Helpers/Filter.cs @@ -1,13 +1,18 @@ +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Text.RegularExpressions; +#if !NETCOREAPP +using CodeWalker.Cli.Polyfills; +#endif + namespace CodeWalker.Cli.Helpers; /// /// Provides methods for filtering file paths based on glob patterns. /// -public static class Filter +internal static class Filter { private static readonly ConcurrentDictionary RegexCache = new(); @@ -26,7 +31,7 @@ public static string[] Normalize(string[]? filters) continue; result.Add(filter.Trim().ToLowerInvariant()); } - return result.ToArray(); + return [.. result]; } /// @@ -58,23 +63,23 @@ private static bool MatchesGlob(string input, string pattern) input = input.Replace('\\', '/'); pattern = pattern.Replace('\\', '/'); - bool hasPathSep = pattern.Contains("/"); + bool hasPathSep = pattern.Contains('/', StringComparison.Ordinal); // For patterns without path separators, match against filename only if (!hasPathSep) { - int lastSlash = input.LastIndexOf("/"); + int lastSlash = input.LastIndexOf('/'); if (lastSlash >= 0) input = input[(lastSlash + 1)..]; } // Handle extension-only patterns (e.g., ".ydr" or "ydr" without wildcards) - if (!pattern.Contains("*") && !pattern.Contains("?")) + if (!pattern.Contains('*', StringComparison.Ordinal) && !pattern.Contains('?', StringComparison.Ordinal)) { - if (pattern.StartsWith(".")) - return input.EndsWith(pattern); + if (pattern.StartsWith('.')) + return input.EndsWith(pattern, System.StringComparison.Ordinal); else - return input.EndsWith("." + pattern); + return input.EndsWith($".{pattern}", System.StringComparison.Ordinal); } Regex regex = RegexCache.GetOrAdd( @@ -87,20 +92,20 @@ private static bool MatchesGlob(string input, string pattern) // Handle ** (globstar) before * — order matters // **/ matches zero or more directory segments - regexPattern = regexPattern.Replace("\\*\\*/", "(.*/)?"); + regexPattern = regexPattern.Replace("\\*\\*/", "(.*/)?", StringComparison.Ordinal); // standalone ** matches any characters including / - regexPattern = regexPattern.Replace("\\*\\*", ".*"); + regexPattern = regexPattern.Replace("\\*\\*", ".*", StringComparison.Ordinal); // * matches any characters except / (single path segment) - regexPattern = regexPattern.Replace("\\*", "[^/]*"); + regexPattern = regexPattern.Replace("\\*", "[^/]*", StringComparison.Ordinal); // ? matches any single character except / - regexPattern = regexPattern.Replace("\\?", "[^/]"); + regexPattern = regexPattern.Replace("\\?", "[^/]", StringComparison.Ordinal); // Patterns with path separators match at any path boundary; // filename-only patterns are anchored to the full filename. - if (p.Contains("/")) - regexPattern = "(?:^|/)" + regexPattern + "$"; + if (p.Contains('/', StringComparison.Ordinal)) + regexPattern = $"(?:^|/){regexPattern}$"; else - regexPattern = "^" + regexPattern + "$"; + regexPattern = $"^{regexPattern}$"; return new Regex(regexPattern, RegexOptions.Compiled); } diff --git a/CodeWalker.Cli/Helpers/ProgressBar.cs b/CodeWalker.Cli/Helpers/ProgressBar.cs index 3c5b2909a..1d35f195a 100644 --- a/CodeWalker.Cli/Helpers/ProgressBar.cs +++ b/CodeWalker.Cli/Helpers/ProgressBar.cs @@ -7,7 +7,7 @@ namespace CodeWalker.Cli.Helpers; /// /// Displays a console progress bar on stderr to keep stdout clean for data/JSON output. /// -public class ProgressBar : IDisposable +internal sealed class ProgressBar : IDisposable { private readonly int _total; private int _current; @@ -98,7 +98,7 @@ private void Render(string? currentFile = null) Err.Write(">"); Err.Write(new string(' ', _barWidth - filled - 1)); } - Err.Write($"] {percent, 6:P0} ({_current}/{_total})"); + Err.Write($"] {percent,6:P0} ({_current}/{_total})"); if (!string.IsNullOrEmpty(currentFile)) { @@ -137,7 +137,8 @@ public void Dispose() Console.CursorVisible = true; } catch (Exception ex) - when (ex is IOException or InvalidOperationException or SecurityException) { } + when (ex is IOException or InvalidOperationException or SecurityException) + { } } } } diff --git a/CodeWalker.Cli/Helpers/SizeFormat.cs b/CodeWalker.Cli/Helpers/SizeFormat.cs index 8b20fcd8a..3ef83ed58 100644 --- a/CodeWalker.Cli/Helpers/SizeFormat.cs +++ b/CodeWalker.Cli/Helpers/SizeFormat.cs @@ -5,19 +5,19 @@ namespace CodeWalker.Cli.Helpers; /// /// Defines size formatting options for human-readable file sizes. /// -public enum SizeFormat +internal enum SizeFormat { /// IEC format: 1024-based (KiB, MiB, GiB) - IEC, + IEC = 0, /// SI format: 1000-based (KB, MB, GB) - SI, + SI = 1, } /// /// Extension methods for SizeFormat to format byte sizes into human-readable strings. /// -public static class SizeFormatExtensions +internal static class SizeFormatExtensions { private static readonly string[] SiSuffixes = ["B", "KB", "MB", "GB", "TB", "PB"]; private static readonly string[] IecSuffixes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; diff --git a/CodeWalker.Cli/InspectHandler.cs b/CodeWalker.Cli/InspectHandler.cs index c26501149..1ebfa3cdd 100644 --- a/CodeWalker.Cli/InspectHandler.cs +++ b/CodeWalker.Cli/InspectHandler.cs @@ -2,15 +2,16 @@ using System.Collections.Generic; using System.CommandLine; using System.IO; -using System.Linq; using System.Text.Json; + using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; + using SharpDX; namespace CodeWalker.Cli; -public static class InspectHandler +internal static class InspectHandler { public static Command CreateCommand() { @@ -31,9 +32,8 @@ public static Command CreateCommand() command.Aliases.Add("i"); command.SetAction(parseResult => - { - return Execute(rpfOpts.Parse(parseResult), parseResult.GetRequiredValue(pathArg)); - }); + Execute(rpfOpts.Parse(parseResult), parseResult.GetRequiredValue(pathArg)) + ); return command; } @@ -120,7 +120,7 @@ Json.InspectResult ErrorResult(string[] errorMessages) => : null, EncryptionType = found is RpfBinaryFileEntry bfe2 ? bfe2.EncryptionType : null, Details = GetDetails(found, ext), - ErrorMessages = scanErrors.ToArray(), + ErrorMessages = [.. scanErrors], }; if (options.Json) @@ -155,8 +155,7 @@ Json.InspectResult ErrorResult(string[] errorMessages) => { if ( entry is RpfFileEntry fileEntry - && entry.Path != null - && entry.Path.Replace('\\', '/').ToLowerInvariant() == normalizedPath + && entry.Path?.Replace('\\', '/').Equals(normalizedPath, StringComparison.OrdinalIgnoreCase) == true ) { return fileEntry; @@ -181,29 +180,19 @@ entry is RpfFileEntry fileEntry { try { - switch (ext) + return ext switch { - case ".ytd": - return GetYtdDetails(entry); - case ".ydr": - return GetYdrDetails(entry); - case ".ydd": - return GetYddDetails(entry); - case ".yft": - return GetYftDetails(entry); - case ".ymap": - return GetYmapDetails(entry); - case ".ytyp": - return GetYtypDetails(entry); - case ".ybn": - return GetYbnDetails(entry); - case ".awc": - return GetAwcDetails(entry); - case ".gxt2": - return GetGxt2Details(entry); - default: - return null; - } + ".ytd" => GetYtdDetails(entry), + ".ydr" => GetYdrDetails(entry), + ".ydd" => GetYddDetails(entry), + ".yft" => GetYftDetails(entry), + ".ymap" => GetYmapDetails(entry), + ".ytyp" => GetYtypDetails(entry), + ".ybn" => GetYbnDetails(entry), + ".awc" => GetAwcDetails(entry), + ".gxt2" => GetGxt2Details(entry), + _ => null, + }; } catch { @@ -444,7 +433,7 @@ entry is RpfFileEntry fileEntry var e = file.TextEntries[i]; string text = e.Text ?? ""; if (text.Length > 100) - text = text.Substring(0, 100) + "..."; + text = text[..100] + "..."; infos.Add(new Json.Gxt2EntryInfo { Hash = $"0x{e.Hash:X8}", Text = text }); } diff --git a/CodeWalker.Cli/Json/DiffResult.cs b/CodeWalker.Cli/Json/DiffResult.cs index f347e8641..481e583f4 100644 --- a/CodeWalker.Cli/Json/DiffResult.cs +++ b/CodeWalker.Cli/Json/DiffResult.cs @@ -3,7 +3,7 @@ namespace CodeWalker.Cli.Json; -public record DiffResult : BaseResult +internal sealed record DiffResult : BaseResult { [JsonPropertyName("leftRpf")] public required string LeftRpf { get; init; } @@ -27,7 +27,7 @@ public record DiffResult : BaseResult public required DiffSummary Summary { get; init; } } -public record DiffEntry +internal sealed record DiffEntry { [JsonPropertyName("path")] public required string Path { get; init; } @@ -55,7 +55,7 @@ public record DiffEntry public long? RightSize { get; init; } } -public record DiffSummary +internal sealed record DiffSummary { [JsonPropertyName("addedCount")] public required int AddedCount { get; init; } diff --git a/CodeWalker.Cli/Json/ExportResult.cs b/CodeWalker.Cli/Json/ExportResult.cs index c5d65a1a1..741fdea19 100644 --- a/CodeWalker.Cli/Json/ExportResult.cs +++ b/CodeWalker.Cli/Json/ExportResult.cs @@ -3,7 +3,7 @@ namespace CodeWalker.Cli.Json; -public record ExportFileEntry +internal sealed record ExportFileEntry { [JsonPropertyName("path")] public required string Path { get; init; } @@ -22,7 +22,7 @@ public record ExportFileEntry public required string Status { get; init; } } -public record ExportResult : BaseResult +internal sealed record ExportResult : BaseResult { [JsonPropertyName("rpfFile")] public required string RpfFile { get; init; } diff --git a/CodeWalker.Cli/Json/ExtractResult.cs b/CodeWalker.Cli/Json/ExtractResult.cs index 80dd493a2..ed7c54bde 100644 --- a/CodeWalker.Cli/Json/ExtractResult.cs +++ b/CodeWalker.Cli/Json/ExtractResult.cs @@ -3,7 +3,7 @@ namespace CodeWalker.Cli.Json; -public record ExtractResult : BaseResult +internal sealed record ExtractResult : BaseResult { [JsonPropertyName("rpfFile")] public required string RpfFile { get; init; } diff --git a/CodeWalker.Cli/Json/FileEntry.cs b/CodeWalker.Cli/Json/FileEntry.cs index c14e9aeb0..ad9f3ca38 100644 --- a/CodeWalker.Cli/Json/FileEntry.cs +++ b/CodeWalker.Cli/Json/FileEntry.cs @@ -2,7 +2,7 @@ namespace CodeWalker.Cli.Json; -public record FileEntry +internal sealed record FileEntry { [JsonPropertyName("path")] public required string Path { get; init; } diff --git a/CodeWalker.Cli/Json/Gen9Result.cs b/CodeWalker.Cli/Json/Gen9Result.cs index 02e87a75b..937acee2d 100644 --- a/CodeWalker.Cli/Json/Gen9Result.cs +++ b/CodeWalker.Cli/Json/Gen9Result.cs @@ -3,7 +3,7 @@ namespace CodeWalker.Cli.Json; -public record Gen9Result : BaseResult +internal sealed record Gen9Result : BaseResult { [JsonPropertyName("inputFolder")] public required string InputFolder { get; init; } @@ -30,7 +30,7 @@ public record Gen9Result : BaseResult public required IReadOnlyList Files { get; init; } } -public record Gen9FileEntry +internal sealed record Gen9FileEntry { [JsonPropertyName("path")] public required string Path { get; init; } diff --git a/CodeWalker.Cli/Json/HashResult.cs b/CodeWalker.Cli/Json/HashResult.cs index 2d724409a..156caec1e 100644 --- a/CodeWalker.Cli/Json/HashResult.cs +++ b/CodeWalker.Cli/Json/HashResult.cs @@ -3,13 +3,13 @@ namespace CodeWalker.Cli.Json; -public record HashResult : BaseResult +internal sealed record HashResult : BaseResult { [JsonPropertyName("hashes")] public required IReadOnlyList Hashes { get; init; } } -public record HashEntry +internal sealed record HashEntry { [JsonPropertyName("input")] public required string Input { get; init; } diff --git a/CodeWalker.Cli/Json/InspectResult.cs b/CodeWalker.Cli/Json/InspectResult.cs index 17b0a5fcf..9bb28a798 100644 --- a/CodeWalker.Cli/Json/InspectResult.cs +++ b/CodeWalker.Cli/Json/InspectResult.cs @@ -3,7 +3,7 @@ namespace CodeWalker.Cli.Json; -public record InspectResult : BaseResult +internal sealed record InspectResult : BaseResult { [JsonPropertyName("rpfFile")] public required string RpfFile { get; init; } @@ -57,7 +57,7 @@ public record InspectResult : BaseResult public object? Details { get; init; } } -public record TextureInfo +internal sealed record TextureInfo { [JsonPropertyName("name")] public required string Name { get; init; } @@ -78,7 +78,7 @@ public record TextureInfo public required ushort Stride { get; init; } } -public record YtdDetails +internal sealed record YtdDetails { [JsonPropertyName("textureCount")] public required int TextureCount { get; init; } @@ -87,7 +87,7 @@ public record YtdDetails public required IReadOnlyList Textures { get; init; } } -public record LodInfo +internal sealed record LodInfo { [JsonPropertyName("level")] public required string Level { get; init; } @@ -105,13 +105,13 @@ public record LodInfo public required long TotalTriangles { get; init; } } -public record YdrDetails +internal sealed record YdrDetails { [JsonPropertyName("lods")] public required IReadOnlyList Lods { get; init; } } -public record DrawableInfo +internal sealed record DrawableInfo { [JsonPropertyName("name")] public required string Name { get; init; } @@ -123,7 +123,7 @@ public record DrawableInfo public required long TotalTriangles { get; init; } } -public record YddDetails +internal sealed record YddDetails { [JsonPropertyName("drawableCount")] public required int DrawableCount { get; init; } @@ -132,7 +132,7 @@ public record YddDetails public required IReadOnlyList Drawables { get; init; } } -public record YftDetails +internal sealed record YftDetails { [JsonPropertyName("lods")] public required IReadOnlyList Lods { get; init; } @@ -141,7 +141,7 @@ public record YftDetails public required bool HasDrawableCloth { get; init; } } -public record YmapDetails +internal sealed record YmapDetails { [JsonPropertyName("entityCount")] public required int EntityCount { get; init; } @@ -169,7 +169,7 @@ public record YmapDetails public required bool IsScripted { get; init; } } -public record YtypDetails +internal sealed record YtypDetails { [JsonPropertyName("archetypeCount")] public required int ArchetypeCount { get; init; } @@ -188,7 +188,7 @@ public record YtypDetails public IReadOnlyList? MloDetails { get; init; } } -public record MloInfo +internal sealed record MloInfo { [JsonPropertyName("name")] public required string Name { get; init; } @@ -203,7 +203,7 @@ public record MloInfo public required int PortalCount { get; init; } } -public record YbnDetails +internal sealed record YbnDetails { [JsonPropertyName("boundsType")] public required string BoundsType { get; init; } @@ -213,7 +213,7 @@ public record YbnDetails public int? ChildCount { get; init; } } -public record AwcStreamInfo +internal sealed record AwcStreamInfo { [JsonPropertyName("id")] public required uint Id { get; init; } @@ -228,7 +228,7 @@ public record AwcStreamInfo public required uint Samples { get; init; } } -public record AwcDetails +internal sealed record AwcDetails { [JsonPropertyName("streamCount")] public required int StreamCount { get; init; } @@ -237,7 +237,7 @@ public record AwcDetails public required IReadOnlyList Streams { get; init; } } -public record Gxt2EntryInfo +internal sealed record Gxt2EntryInfo { [JsonPropertyName("hash")] public required string Hash { get; init; } @@ -246,7 +246,7 @@ public record Gxt2EntryInfo public required string Text { get; init; } } -public record Gxt2Details +internal sealed record Gxt2Details { [JsonPropertyName("entryCount")] public required int EntryCount { get; init; } diff --git a/CodeWalker.Cli/Json/ListResult.cs b/CodeWalker.Cli/Json/ListResult.cs index c8d235a64..30c9c8809 100644 --- a/CodeWalker.Cli/Json/ListResult.cs +++ b/CodeWalker.Cli/Json/ListResult.cs @@ -3,7 +3,7 @@ namespace CodeWalker.Cli.Json; -public record ListResult : BaseResult +internal sealed record ListResult : BaseResult { [JsonPropertyName("rpfFile")] public required string RpfFile { get; init; } diff --git a/CodeWalker.Cli/Json/PackResult.cs b/CodeWalker.Cli/Json/PackResult.cs index a412240c0..15cdddc0a 100644 --- a/CodeWalker.Cli/Json/PackResult.cs +++ b/CodeWalker.Cli/Json/PackResult.cs @@ -1,9 +1,8 @@ -using System.Collections.Generic; using System.Text.Json.Serialization; namespace CodeWalker.Cli.Json; -public record PackResult : BaseResult +internal sealed record PackResult : BaseResult { [JsonPropertyName("inputDir")] public required string InputDir { get; init; } diff --git a/CodeWalker.Cli/Json/SearchResult.cs b/CodeWalker.Cli/Json/SearchResult.cs index 006d14a4e..69885a90e 100644 --- a/CodeWalker.Cli/Json/SearchResult.cs +++ b/CodeWalker.Cli/Json/SearchResult.cs @@ -3,7 +3,7 @@ namespace CodeWalker.Cli.Json; -public record SearchMatch +internal sealed record SearchMatch { [JsonPropertyName("path")] public required string Path { get; init; } @@ -27,7 +27,7 @@ public record SearchMatch public required uint ShortNameHash { get; init; } } -public record SearchResult : BaseResult +internal sealed record SearchResult : BaseResult { [JsonPropertyName("rpfFile")] public required string RpfFile { get; init; } diff --git a/CodeWalker.Cli/Json/StatResult.cs b/CodeWalker.Cli/Json/StatResult.cs index 8baf3f247..7441c0ae5 100644 --- a/CodeWalker.Cli/Json/StatResult.cs +++ b/CodeWalker.Cli/Json/StatResult.cs @@ -3,7 +3,7 @@ namespace CodeWalker.Cli.Json; -public record ExtensionStat +internal sealed record ExtensionStat { [JsonPropertyName("extension")] public required string Extension { get; init; } @@ -27,7 +27,7 @@ public record ExtensionStat public required long MaxSize { get; init; } } -public record StatResult : BaseResult +internal sealed record StatResult : BaseResult { [JsonPropertyName("rpfFile")] public required string RpfFile { get; init; } diff --git a/CodeWalker.Cli/Json/TreeResult.cs b/CodeWalker.Cli/Json/TreeResult.cs index fb11388cc..58cb67e19 100644 --- a/CodeWalker.Cli/Json/TreeResult.cs +++ b/CodeWalker.Cli/Json/TreeResult.cs @@ -3,7 +3,7 @@ namespace CodeWalker.Cli.Json; -public record TreeResult : BaseResult +internal sealed record TreeResult : BaseResult { [JsonPropertyName("rpfFile")] public required string RpfFile { get; init; } @@ -18,7 +18,7 @@ public record TreeResult : BaseResult public TreeNode? Root { get; init; } } -public record TreeNode +internal sealed record TreeNode { [JsonPropertyName("name")] public required string Name { get; init; } diff --git a/CodeWalker.Cli/Json/ValidateResult.cs b/CodeWalker.Cli/Json/ValidateResult.cs index c712a4c37..0defb4a5a 100644 --- a/CodeWalker.Cli/Json/ValidateResult.cs +++ b/CodeWalker.Cli/Json/ValidateResult.cs @@ -3,7 +3,7 @@ namespace CodeWalker.Cli.Json; -public record ValidateFileEntry +internal sealed record ValidateFileEntry { [JsonPropertyName("path")] public required string Path { get; init; } @@ -19,7 +19,7 @@ public record ValidateFileEntry public string? Message { get; init; } } -public record ValidateResult : BaseResult +internal sealed record ValidateResult : BaseResult { [JsonPropertyName("rpfFile")] public required string RpfFile { get; init; } diff --git a/CodeWalker.Cli/ListHandler.cs b/CodeWalker.Cli/ListHandler.cs index f8257f9bf..b3457dc8b 100644 --- a/CodeWalker.Cli/ListHandler.cs +++ b/CodeWalker.Cli/ListHandler.cs @@ -4,12 +4,13 @@ using System.IO; using System.Text.Json; using System.Threading.Tasks; + using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; namespace CodeWalker.Cli; -public static class ListHandler +internal static class ListHandler { public static Command CreateCommand() { @@ -19,10 +20,7 @@ public static Command CreateCommand() rpfOpts.AddTo(command); command.Aliases.Add("l"); - command.SetAction(parseResult => - { - return Execute(rpfOpts.Parse(parseResult)); - }); + command.SetAction(parseResult => Execute(rpfOpts.Parse(parseResult))); return command; } @@ -146,8 +144,8 @@ Json.ListResult ErrorResult(string[] errorMessages) => TotalSize = totalSize, TotalSizeFormatted = options.SizeFormat.ToFormattedString(totalSize), NestedRpfCount = nestedRpfCount, - Files = files.ToArray(), - ErrorMessages = scanErrors.ToArray(), + Files = [.. files], + ErrorMessages = [.. scanErrors], }; if (options.Json) diff --git a/CodeWalker.Cli/PackHandler.cs b/CodeWalker.Cli/PackHandler.cs index e386b4771..e8da7671c 100644 --- a/CodeWalker.Cli/PackHandler.cs +++ b/CodeWalker.Cli/PackHandler.cs @@ -3,12 +3,13 @@ using System.CommandLine; using System.IO; using System.Text.Json; + using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; namespace CodeWalker.Cli; -public record PackOptions +internal sealed record PackOptions { public required string InputPath { get; init; } public required string OutputPath { get; init; } @@ -18,12 +19,11 @@ public record PackOptions public required bool Progress { get; init; } } -public static class PackHandler +internal static class PackHandler { public static Command CreateCommand() { CommonCommandOptions commonOpts = new(); - // csharpier-ignore-start Option inputOption = new("--input", "-i") { Description = "Source directory of loose files to pack", @@ -50,7 +50,6 @@ public static Command CreateCommand() { Description = "Show progress bar", }; - // csharpier-ignore-end Command command = new("pack", "Create an RPF archive from a directory of loose files") { @@ -205,7 +204,7 @@ ref errors TotalSize = totalSize, TotalSizeFormatted = sizeFormat.ToFormattedString(totalSize), Errors = errors, - ErrorMessages = errorMessages.ToArray(), + ErrorMessages = [.. errorMessages], }; if (options.Common.Json) diff --git a/CodeWalker.Cli/Program.cs b/CodeWalker.Cli/Program.cs index 8ebdbac48..4b8525ad2 100644 --- a/CodeWalker.Cli/Program.cs +++ b/CodeWalker.Cli/Program.cs @@ -1,4 +1,5 @@ using System.CommandLine; + using CodeWalker.Cli; RootCommand rootCommand = new(description: "CodeWalker CLI - RPF Archive Tool") diff --git a/CodeWalker.Cli/RpfOptions.cs b/CodeWalker.Cli/RpfOptions.cs index 1d2d41333..58dd973df 100644 --- a/CodeWalker.Cli/RpfOptions.cs +++ b/CodeWalker.Cli/RpfOptions.cs @@ -1,10 +1,11 @@ using System.CommandLine; using System.IO; + using CodeWalker.Cli.Helpers; namespace CodeWalker.Cli; -public record RpfOptions +internal sealed record RpfOptions { public required string RpfPath { get; init; } public required string ExePath { get; init; } @@ -22,11 +23,10 @@ public record RpfOptions /// Create an instance, call to register options on a command, /// then call inside the action to build an . /// -public sealed class RpfCommandOptions +internal sealed class RpfCommandOptions { private readonly CommonCommandOptions _commonOpts = new(); - // csharpier-ignore-start public Option Rpf { get; } = new("--rpf", "-r") { Description = "Path to the RPF file", @@ -48,7 +48,6 @@ public sealed class RpfCommandOptions { Description = "Process nested RPF archives", }; - // csharpier-ignore-end public void AddTo(Command command) { diff --git a/CodeWalker.Cli/RpfService.cs b/CodeWalker.Cli/RpfService.cs index b7b98e4e1..f91886085 100644 --- a/CodeWalker.Cli/RpfService.cs +++ b/CodeWalker.Cli/RpfService.cs @@ -3,12 +3,13 @@ using System.IO; using System.Text.Json; using System.Text.Json.Serialization; + using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; namespace CodeWalker.Cli; -public abstract record BaseResult +internal abstract record BaseResult { [JsonPropertyName("success")] [JsonPropertyOrder(-1)] @@ -19,7 +20,7 @@ public abstract record BaseResult public required IReadOnlyList ErrorMessages { get; init; } } -public static class RpfService +internal static class RpfService { public static readonly JsonSerializerOptions JsonSerializerOptions = new() { @@ -102,7 +103,7 @@ private static void CollectFilesRecursive( { if (entry is RpfFileEntry fileEntry) { - if (entry.NameLower.EndsWith(".rpf")) + if (entry.NameLower.EndsWith(".rpf", StringComparison.Ordinal)) continue; if (!Filter.Matches(entry.Path, filters)) @@ -135,7 +136,7 @@ private static void CountNonRpfFilesRecursive(RpfFile rpf, bool recursive, ref i { foreach (RpfEntry entry in rpf.AllEntries) { - if (entry is RpfFileEntry && !entry.NameLower.EndsWith(".rpf")) + if (entry is RpfFileEntry && !entry.NameLower.EndsWith(".rpf", StringComparison.Ordinal)) { count++; } diff --git a/CodeWalker.Cli/SearchHandler.cs b/CodeWalker.Cli/SearchHandler.cs index 3d8632f4d..358e115f8 100644 --- a/CodeWalker.Cli/SearchHandler.cs +++ b/CodeWalker.Cli/SearchHandler.cs @@ -4,16 +4,17 @@ using System.IO; using System.Text.Json; using System.Threading.Tasks; + using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; + #if !NETCOREAPP using CodeWalker.Cli.Polyfills; #endif - namespace CodeWalker.Cli; -public static class SearchHandler +internal static class SearchHandler { public static Command CreateCommand() { @@ -31,9 +32,8 @@ public static Command CreateCommand() command.Aliases.Add("s"); command.SetAction(parseResult => - { - return Execute(rpfOpts.Parse(parseResult), parseResult.GetRequiredValue(patternArg)); - }); + Execute(rpfOpts.Parse(parseResult), parseResult.GetRequiredValue(patternArg)) + ); return command; } @@ -92,7 +92,7 @@ Json.SearchResult ErrorResult(string[] errorMessages) => patternType = "hash_hex"; if ( !uint.TryParse( - pattern.Substring(2), + pattern[2..], System.Globalization.NumberStyles.HexNumber, null, out uint hash @@ -130,8 +130,7 @@ out uint hash patternType = "substring"; string lowerPattern = pattern.ToLowerInvariant(); matcher = entry => - entry.Path != null - && entry.Path.Replace('\\', '/').ToLowerInvariant().Contains(lowerPattern); + entry.Path?.Replace('\\', '/').Contains(lowerPattern, StringComparison.OrdinalIgnoreCase) == true; } // Match in parallel @@ -187,7 +186,7 @@ out uint hash PatternType = patternType, MatchCount = matches.Count, Matches = matches, - ErrorMessages = scanErrors.ToArray(), + ErrorMessages = [.. scanErrors], }; if (options.Json) @@ -234,17 +233,14 @@ out uint hash private static bool HasGlobChars(string s) { - return s.Contains('*') || s.Contains('?') || s.Contains('['); + return s.Contains('*', StringComparison.Ordinal) || s.Contains('?', StringComparison.Ordinal) || s.Contains('[', StringComparison.Ordinal); } private static void CollectAllEntries(RpfFile rpf, bool recursive, List entries) { if (rpf.AllEntries != null) { - foreach (RpfEntry entry in rpf.AllEntries) - { - entries.Add(entry); - } + entries.AddRange(rpf.AllEntries); } if (recursive && rpf.Children != null) diff --git a/CodeWalker.Cli/StatHandler.cs b/CodeWalker.Cli/StatHandler.cs index 381c46b0d..e657f77a1 100644 --- a/CodeWalker.Cli/StatHandler.cs +++ b/CodeWalker.Cli/StatHandler.cs @@ -4,12 +4,13 @@ using System.IO; using System.Linq; using System.Text.Json; + using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; namespace CodeWalker.Cli; -public static class StatHandler +internal static class StatHandler { public static Command CreateCommand() { @@ -19,10 +20,7 @@ public static Command CreateCommand() rpfOpts.AddTo(command); command.Aliases.Add("S"); - command.SetAction(parseResult => - { - return Execute(rpfOpts.Parse(parseResult)); - }); + command.SetAction(parseResult => Execute(rpfOpts.Parse(parseResult))); return command; } @@ -124,19 +122,21 @@ Json.StatResult ErrorResult(string[] errorMessages) => double compressionRatio = uncompressedSize > 0 ? (double)compressedSize / uncompressedSize : 0; - List extensionStats = extStats - .OrderByDescending(kv => kv.Value.total) - .Select(kv => new Json.ExtensionStat - { - Extension = kv.Key, - Count = kv.Value.count, - TotalSize = kv.Value.total, - TotalSizeFormatted = options.SizeFormat.ToFormattedString(kv.Value.total), - AvgSize = kv.Value.count > 0 ? kv.Value.total / kv.Value.count : 0, - MinSize = kv.Value.min, - MaxSize = kv.Value.max, - }) - .ToList(); + List extensionStats = + [ + .. extStats + .OrderByDescending(kv => kv.Value.total) + .Select(kv => new Json.ExtensionStat + { + Extension = kv.Key, + Count = kv.Value.count, + TotalSize = kv.Value.total, + TotalSizeFormatted = options.SizeFormat.ToFormattedString(kv.Value.total), + AvgSize = kv.Value.count > 0 ? kv.Value.total / kv.Value.count : 0, + MinSize = kv.Value.min, + MaxSize = kv.Value.max, + }), + ]; Json.StatResult result = new() { @@ -151,7 +151,7 @@ Json.StatResult ErrorResult(string[] errorMessages) => UncompressedSize = uncompressedSize, CompressionRatio = Math.Round(compressionRatio, 4), Extensions = extensionStats, - ErrorMessages = scanErrors.ToArray(), + ErrorMessages = [.. scanErrors], }; if (options.Json) @@ -164,14 +164,14 @@ Json.StatResult ErrorResult(string[] errorMessages) => { // Table header Console.WriteLine( - $"{"Extension", -12} {"Count", 8} {"Total", 14} {"Avg", 14} {"Min", 14} {"Max", 14}" + $"{"Extension",-12} {"Count",8} {"Total",14} {"Avg",14} {"Min",14} {"Max",14}" ); Console.WriteLine(new string('-', 78)); foreach (Json.ExtensionStat ext in extensionStats) { Console.WriteLine( - $"{ext.Extension, -12} {ext.Count, 8} {options.SizeFormat.ToFormattedString(ext.TotalSize), 14} {options.SizeFormat.ToFormattedString(ext.AvgSize), 14} {options.SizeFormat.ToFormattedString(ext.MinSize), 14} {options.SizeFormat.ToFormattedString(ext.MaxSize), 14}" + $"{ext.Extension,-12} {ext.Count,8} {options.SizeFormat.ToFormattedString(ext.TotalSize),14} {options.SizeFormat.ToFormattedString(ext.AvgSize),14} {options.SizeFormat.ToFormattedString(ext.MinSize),14} {options.SizeFormat.ToFormattedString(ext.MaxSize),14}" ); } diff --git a/CodeWalker.Cli/TreeHandler.cs b/CodeWalker.Cli/TreeHandler.cs index 73a398c64..2c7dae530 100644 --- a/CodeWalker.Cli/TreeHandler.cs +++ b/CodeWalker.Cli/TreeHandler.cs @@ -3,29 +3,28 @@ using System.CommandLine; using System.IO; using System.Text.Json; + using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; namespace CodeWalker.Cli; -public record TreeOptions +internal sealed record TreeOptions { public required RpfOptions Rpf { get; init; } public required int Depth { get; init; } } -public static class TreeHandler +internal static class TreeHandler { public static Command CreateCommand() { RpfCommandOptions rpfOpts = new(); - // csharpier-ignore-start Option depthOption = new("--depth", "-d") { Description = "Maximum depth to display (default: unlimited)", DefaultValueFactory = _ => -1, }; - // csharpier-ignore-end depthOption.Validators.Add(result => { @@ -107,7 +106,7 @@ ref totalDirs TotalFiles = totalFiles, TotalDirs = totalDirs, Root = rootNode, - ErrorMessages = scanErrors.ToArray(), + ErrorMessages = [.. scanErrors], }; Console.WriteLine( @@ -303,7 +302,7 @@ ref totalDirs { foreach (RpfFileEntry fileEntry in dir.Files) { - if (fileEntry.NameLower.EndsWith(".rpf") && rpf.Children != null) + if (fileEntry.NameLower.EndsWith(".rpf", StringComparison.Ordinal) && rpf.Children != null) { foreach (RpfFile child in rpf.Children) { @@ -322,7 +321,7 @@ ref totalDirs { foreach (RpfFileEntry fileEntry in dir.Files) { - if (fileEntry.NameLower.EndsWith(".rpf")) + if (fileEntry.NameLower.EndsWith(".rpf", StringComparison.Ordinal)) continue; if (!Filter.Matches(fileEntry.Path, options.Rpf.Filters)) diff --git a/CodeWalker.Cli/ValidateHandler.cs b/CodeWalker.Cli/ValidateHandler.cs index a905b9c46..3132f52e1 100644 --- a/CodeWalker.Cli/ValidateHandler.cs +++ b/CodeWalker.Cli/ValidateHandler.cs @@ -3,30 +3,28 @@ using System.CommandLine; using System.IO; using System.Text.Json; -using System.Threading; using System.Threading.Tasks; + using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; namespace CodeWalker.Cli; -public record ValidateOptions +internal sealed record ValidateOptions { public required RpfOptions Rpf { get; init; } public required bool Progress { get; init; } } -public static class ValidateHandler +internal static class ValidateHandler { public static Command CreateCommand() { RpfCommandOptions rpfOpts = new(); - // csharpier-ignore-start Option progressOption = new("--progress", "-P") { Description = "Show progress bar during validation", }; - // csharpier-ignore-end Command command = new("validate", "Validate game file integrity by parsing RPF contents") { @@ -113,7 +111,6 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => try { (string status, string? message) = ValidateFile( - sourceRpf, fileEntry, ext ); @@ -211,7 +208,7 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => Errors = errors, Skipped = skipped, Files = files, - ErrorMessages = scanErrors.ToArray(), + ErrorMessages = [.. scanErrors], }; if (options.Rpf.Json) @@ -242,7 +239,6 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => } private static (string status, string? message) ValidateFile( - RpfFile sourceRpf, RpfFileEntry fileEntry, string ext ) @@ -250,89 +246,89 @@ string ext switch (ext) { case ".ytd": - { - YtdFile file = RpfFile.GetFile(fileEntry); - if (file == null) - return ("error", "Failed to load YTD file"); - if ( - file.TextureDict?.Textures?.data_items == null - || file.TextureDict.Textures.data_items.Length == 0 - ) - return ("warning", "Texture dictionary is empty"); - return ("valid", null); - } + { + YtdFile file = RpfFile.GetFile(fileEntry); + if (file == null) + return ("error", "Failed to load YTD file"); + if ( + file.TextureDict?.Textures?.data_items == null + || file.TextureDict.Textures.data_items.Length == 0 + ) + return ("warning", "Texture dictionary is empty"); + return ("valid", null); + } case ".ydr": - { - YdrFile file = RpfFile.GetFile(fileEntry); - if (file == null) - return ("error", "Failed to load YDR file"); - if (file.Drawable == null) - return ("error", "Drawable is null"); - return ("valid", null); - } + { + YdrFile file = RpfFile.GetFile(fileEntry); + if (file == null) + return ("error", "Failed to load YDR file"); + if (file.Drawable == null) + return ("error", "Drawable is null"); + return ("valid", null); + } case ".ydd": - { - YddFile file = RpfFile.GetFile(fileEntry); - if (file == null) - return ("error", "Failed to load YDD file"); - if (file.DrawableDict == null) - return ("error", "DrawableDict is null"); - return ("valid", null); - } + { + YddFile file = RpfFile.GetFile(fileEntry); + if (file == null) + return ("error", "Failed to load YDD file"); + if (file.DrawableDict == null) + return ("error", "DrawableDict is null"); + return ("valid", null); + } case ".yft": - { - YftFile file = RpfFile.GetFile(fileEntry); - if (file == null) - return ("error", "Failed to load YFT file"); - if (file.Fragment == null) - return ("error", "Fragment is null"); - return ("valid", null); - } + { + YftFile file = RpfFile.GetFile(fileEntry); + if (file == null) + return ("error", "Failed to load YFT file"); + if (file.Fragment == null) + return ("error", "Fragment is null"); + return ("valid", null); + } case ".ymap": - { - YmapFile file = RpfFile.GetFile(fileEntry); - if (file == null) - return ("error", "Failed to load YMAP file"); - if (file.AllEntities == null || file.AllEntities.Length == 0) - return ("warning", "No entities found"); - return ("valid", null); - } + { + YmapFile file = RpfFile.GetFile(fileEntry); + if (file == null) + return ("error", "Failed to load YMAP file"); + if (file.AllEntities == null || file.AllEntities.Length == 0) + return ("warning", "No entities found"); + return ("valid", null); + } case ".ytyp": - { - YtypFile file = RpfFile.GetFile(fileEntry); - if (file == null) - return ("error", "Failed to load YTYP file"); - if (file.AllArchetypes == null || file.AllArchetypes.Length == 0) - return ("warning", "No archetypes found"); - return ("valid", null); - } + { + YtypFile file = RpfFile.GetFile(fileEntry); + if (file == null) + return ("error", "Failed to load YTYP file"); + if (file.AllArchetypes == null || file.AllArchetypes.Length == 0) + return ("warning", "No archetypes found"); + return ("valid", null); + } case ".ybn": - { - YbnFile file = RpfFile.GetFile(fileEntry); - if (file == null) - return ("error", "Failed to load YBN file"); - if (file.Bounds == null) - return ("error", "Bounds is null"); - return ("valid", null); - } + { + YbnFile file = RpfFile.GetFile(fileEntry); + if (file == null) + return ("error", "Failed to load YBN file"); + if (file.Bounds == null) + return ("error", "Bounds is null"); + return ("valid", null); + } case ".awc": - { - AwcFile file = RpfFile.GetFile(fileEntry); - if (file == null) - return ("error", "Failed to load AWC file"); - if (file.Streams == null || file.Streams.Length == 0) - return ("warning", "No audio streams found"); - return ("valid", null); - } + { + AwcFile file = RpfFile.GetFile(fileEntry); + if (file == null) + return ("error", "Failed to load AWC file"); + if (file.Streams == null || file.Streams.Length == 0) + return ("warning", "No audio streams found"); + return ("valid", null); + } case ".gxt2": - { - Gxt2File file = RpfFile.GetFile(fileEntry); - if (file == null) - return ("error", "Failed to load GXT2 file"); - if (file.TextEntries == null || file.TextEntries.Length == 0) - return ("warning", "No text entries found"); - return ("valid", null); - } + { + Gxt2File file = RpfFile.GetFile(fileEntry); + if (file == null) + return ("error", "Failed to load GXT2 file"); + if (file.TextEntries == null || file.TextEntries.Length == 0) + return ("warning", "No text entries found"); + return ("valid", null); + } default: return ("skipped", null); } From 7bfc629698d1e8a850ffe6cf8c606d45cefeae3c Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:26:32 +0200 Subject: [PATCH 09/45] fix(cli): make Success, the exit code and the counts agree The JSON contract says success is false when errorMessages is non-empty, and the exit code is 1 in the same case. Neither held. - diff set Success to a literal true, so a failed comparison still reported success. - export, extract, gen9, pack and validate derived Success and the exit code from their own error counters, which did not include the scan errors collected while opening the archive. An archive that failed to scan reported success and exited 0. - inspect and search returned 0 unconditionally. The counts were as inconsistent. export reported GrandTotalFileCount as totalFiles, which counts nested archives as files; a dry run counted nothing as exported because the status is "dry_run" and only "exported" was matched; export marked a format it does not handle as "skipped", which is also what --no-overwrite reports, so the two could not be told apart, and it is "unsupported" now. The rest are smaller: pack never set RpfManager.IsGen9, so --gen9 did nothing there; diff's JSON was missing the formatted sizes its text output printed; the progress bar could compute a fill wider than the bar; inspect built its per-type details with untyped casts; the exporters created the output directory before knowing whether the file would produce anything, so an archive of unsupported files left a tree of empty directories; pack accepted --threads without using it. --- CodeWalker.Cli/CommonOptions.cs | 5 +- CodeWalker.Cli/Compiler.cs | 2 +- CodeWalker.Cli/DiffHandler.cs | 8 ++- CodeWalker.Cli/ExportAudioHandler.cs | 26 +++++--- CodeWalker.Cli/ExportService.cs | 8 +-- CodeWalker.Cli/ExportTextHandler.cs | 16 ++--- CodeWalker.Cli/ExportTexturesHandler.cs | 21 ++++--- CodeWalker.Cli/ExportXmlHandler.cs | 2 +- CodeWalker.Cli/ExtractHandler.cs | 8 +-- CodeWalker.Cli/Gen9Handler.cs | 17 +++++- CodeWalker.Cli/Helpers/Filter.cs | 4 +- CodeWalker.Cli/Helpers/ProgressBar.cs | 2 +- CodeWalker.Cli/InspectHandler.cs | 46 ++++++++++---- CodeWalker.Cli/Json/DiffResult.cs | 8 +++ CodeWalker.Cli/Json/InspectResult.cs | 6 +- CodeWalker.Cli/Json/ListResult.cs | 2 +- CodeWalker.Cli/Json/TreeResult.cs | 2 +- CodeWalker.Cli/ListHandler.cs | 80 ++++++++----------------- CodeWalker.Cli/PackHandler.cs | 8 ++- CodeWalker.Cli/RpfService.cs | 26 ++++---- CodeWalker.Cli/SearchHandler.cs | 8 +-- CodeWalker.Cli/StatHandler.cs | 8 +-- CodeWalker.Cli/TreeHandler.cs | 5 +- CodeWalker.Cli/ValidateHandler.cs | 4 +- 24 files changed, 181 insertions(+), 141 deletions(-) diff --git a/CodeWalker.Cli/CommonOptions.cs b/CodeWalker.Cli/CommonOptions.cs index 56454fa68..de1d2e7cb 100644 --- a/CodeWalker.Cli/CommonOptions.cs +++ b/CodeWalker.Cli/CommonOptions.cs @@ -58,13 +58,14 @@ public CommonCommandOptions() }); } - public void AddTo(Command command) + public void AddTo(Command command, bool includeThreads = true) { command.Add(Exe); command.Add(Verbose); command.Add(Json); command.Add(Si); - command.Add(Threads); + if (includeThreads) + command.Add(Threads); } public CommonOptions Parse(ParseResult parseResult) diff --git a/CodeWalker.Cli/Compiler.cs b/CodeWalker.Cli/Compiler.cs index 447cad949..88849b57e 100644 --- a/CodeWalker.Cli/Compiler.cs +++ b/CodeWalker.Cli/Compiler.cs @@ -87,7 +87,7 @@ private static string ReplaceInternal( StringComparison comparisonType ) { - StringBuilder? sb = new(); + StringBuilder sb = new(); int start = 0; int index; while ((index = s.IndexOf(oldValue, start, comparisonType)) >= 0) diff --git a/CodeWalker.Cli/DiffHandler.cs b/CodeWalker.Cli/DiffHandler.cs index 9914efe86..21571d7d2 100644 --- a/CodeWalker.Cli/DiffHandler.cs +++ b/CodeWalker.Cli/DiffHandler.cs @@ -230,7 +230,9 @@ Json.DiffResult ErrorResult(string[] errorMessages) => Name = leftEntry.Name, Type = RpfService.GetFileType(leftEntry), LeftSize = leftSize, + LeftSizeFormatted = sizeFormat.ToFormattedString(leftSize), RightSize = rightSize, + RightSizeFormatted = sizeFormat.ToFormattedString(rightSize), } ); } @@ -304,7 +306,7 @@ Json.DiffResult ErrorResult(string[] errorMessages) => Json.DiffResult result = new() { - Success = true, + Success = errorMessages.Count == 0, LeftRpf = options.LeftPath, RightRpf = options.RightPath, Added = [.. added], @@ -349,7 +351,7 @@ Json.DiffResult ErrorResult(string[] errorMessages) => foreach (Json.DiffEntry entry in modified) { Console.WriteLine( - $" ~ {entry.Path} ({sizeFormat.ToFormattedString(entry.LeftSize ?? 0)} -> {sizeFormat.ToFormattedString(entry.RightSize ?? 0)})" + $" ~ {entry.Path} ({entry.LeftSizeFormatted} -> {entry.RightSizeFormatted})" ); } Console.WriteLine(); @@ -370,7 +372,7 @@ Json.DiffResult ErrorResult(string[] errorMessages) => ); } - return 0; + return errorMessages.Count > 0 ? 1 : 0; } catch (Exception ex) { diff --git a/CodeWalker.Cli/ExportAudioHandler.cs b/CodeWalker.Cli/ExportAudioHandler.cs index e418fcc0c..fcc39f9c9 100644 --- a/CodeWalker.Cli/ExportAudioHandler.cs +++ b/CodeWalker.Cli/ExportAudioHandler.cs @@ -31,7 +31,7 @@ public static Command CreateCommand() return command; } - private static (Json.ExportFileEntry? entry, string? error) ProcessFile( + private static (Json.ExportFileEntry entry, string? _) ProcessFile( RpfFileEntry fileEntry, byte[] data, string fileOutputDir, @@ -47,17 +47,13 @@ bool noOverwrite Path = fileEntry.Path, Name = fileEntry.Name, OutputFiles = 0, - Status = "skipped", + Status = "unsupported", }, null ); } - if (!Directory.Exists(fileOutputDir)) - { - Directory.CreateDirectory(fileOutputDir); - } - + bool dirCreated = false; int streamCount = 0; foreach (AwcStream stream in awc.Streams) { @@ -71,6 +67,12 @@ bool noOverwrite string midiPath = Path.Combine(fileOutputDir, streamName + ".midi"); if (noOverwrite && File.Exists(midiPath)) continue; + if (!dirCreated) + { + if (!Directory.Exists(fileOutputDir)) + Directory.CreateDirectory(fileOutputDir); + dirCreated = true; + } File.WriteAllBytes(midiPath, stream.MidiChunk.Data); streamCount++; } @@ -80,6 +82,12 @@ bool noOverwrite string wavPath = Path.Combine(fileOutputDir, streamName + ".wav"); if (noOverwrite && File.Exists(wavPath)) continue; + if (!dirCreated) + { + if (!Directory.Exists(fileOutputDir)) + Directory.CreateDirectory(fileOutputDir); + dirCreated = true; + } File.WriteAllBytes(wavPath, wav); streamCount++; } @@ -90,9 +98,9 @@ bool noOverwrite { Path = fileEntry.Path, Name = fileEntry.Name, - OutputPath = fileOutputDir, + OutputPath = streamCount > 0 ? fileOutputDir : null, OutputFiles = streamCount, - Status = "exported", + Status = streamCount > 0 ? "exported" : "skipped", }, null ); diff --git a/CodeWalker.Cli/ExportService.cs b/CodeWalker.Cli/ExportService.cs index d196911ce..2086e252b 100644 --- a/CodeWalker.Cli/ExportService.cs +++ b/CodeWalker.Cli/ExportService.cs @@ -221,7 +221,7 @@ Json.ExportResult ErrorResult(string[] errorMessages) => foreach (var (success, jsonEntry, errorMessage) in results) { - if (success && jsonEntry?.Status == "exported") + if (success && jsonEntry?.Status is "exported" or "dry_run") exported++; if (jsonEntry?.Status is "unsupported" or "skipped") @@ -241,11 +241,11 @@ Json.ExportResult ErrorResult(string[] errorMessages) => Json.ExportResult result = new() { - Success = errors == 0, + Success = errors == 0 && scanErrors.Count == 0, RpfFile = options.Rpf.RpfPath, OutputDir = options.OutputPath, Format = format, - TotalFiles = filesToExport.Count, + TotalFiles = totalNonRpfFiles, Exported = exported, Skipped = skipped, Errors = errors, @@ -269,7 +269,7 @@ Json.ExportResult ErrorResult(string[] errorMessages) => ); } - return errors > 0 ? 1 : 0; + return (errors > 0 || scanErrors.Count > 0) ? 1 : 0; } catch (Exception ex) { diff --git a/CodeWalker.Cli/ExportTextHandler.cs b/CodeWalker.Cli/ExportTextHandler.cs index 66373c851..a8d2344d1 100644 --- a/CodeWalker.Cli/ExportTextHandler.cs +++ b/CodeWalker.Cli/ExportTextHandler.cs @@ -32,7 +32,7 @@ public static Command CreateCommand() return command; } - private static (Json.ExportFileEntry? entry, string? error) ProcessFile( + private static (Json.ExportFileEntry entry, string? _) ProcessFile( RpfFileEntry fileEntry, byte[] data, string fileOutputDir, @@ -48,7 +48,7 @@ bool noOverwrite Path = fileEntry.Path, Name = fileEntry.Name, OutputFiles = 0, - Status = "skipped", + Status = "unsupported", }, null ); @@ -64,17 +64,12 @@ bool noOverwrite Path = fileEntry.Path, Name = fileEntry.Name, OutputFiles = 0, - Status = "skipped", + Status = "unsupported", }, null ); } - if (!Directory.Exists(fileOutputDir)) - { - Directory.CreateDirectory(fileOutputDir); - } - string outputFileName = Path.GetFileNameWithoutExtension(fileEntry.Name) + ".txt"; string outputPath = Path.Combine(fileOutputDir, outputFileName); @@ -92,6 +87,11 @@ bool noOverwrite ); } + if (!Directory.Exists(fileOutputDir)) + { + Directory.CreateDirectory(fileOutputDir); + } + File.WriteAllText(outputPath, text, Encoding.UTF8); return ( diff --git a/CodeWalker.Cli/ExportTexturesHandler.cs b/CodeWalker.Cli/ExportTexturesHandler.cs index 1f8e58541..e5974ff57 100644 --- a/CodeWalker.Cli/ExportTexturesHandler.cs +++ b/CodeWalker.Cli/ExportTexturesHandler.cs @@ -32,7 +32,7 @@ public static Command CreateCommand() return command; } - private static (Json.ExportFileEntry? entry, string? error) ProcessFile( + private static (Json.ExportFileEntry entry, string? _) ProcessFile( RpfFileEntry fileEntry, byte[] data, string fileOutputDir, @@ -51,17 +51,13 @@ bool noOverwrite Path = fileEntry.Path, Name = fileEntry.Name, OutputFiles = 0, - Status = "skipped", + Status = "unsupported", }, null ); } - if (!Directory.Exists(fileOutputDir)) - { - Directory.CreateDirectory(fileOutputDir); - } - + bool dirCreated = false; int texCount = 0; foreach (Texture tex in ytd.TextureDict.Textures.data_items) { @@ -71,6 +67,13 @@ bool noOverwrite if (noOverwrite && File.Exists(outputPath)) continue; + if (!dirCreated) + { + if (!Directory.Exists(fileOutputDir)) + Directory.CreateDirectory(fileOutputDir); + dirCreated = true; + } + byte[] dds = DDSIO.GetDDSFile(tex); File.WriteAllBytes(outputPath, dds); texCount++; @@ -81,9 +84,9 @@ bool noOverwrite { Path = fileEntry.Path, Name = fileEntry.Name, - OutputPath = fileOutputDir, + OutputPath = texCount > 0 ? fileOutputDir : null, OutputFiles = texCount, - Status = "exported", + Status = texCount > 0 ? "exported" : "skipped", }, null ); diff --git a/CodeWalker.Cli/ExportXmlHandler.cs b/CodeWalker.Cli/ExportXmlHandler.cs index 0daac2b00..ecc8eaccc 100644 --- a/CodeWalker.Cli/ExportXmlHandler.cs +++ b/CodeWalker.Cli/ExportXmlHandler.cs @@ -25,7 +25,7 @@ public static Command CreateCommand() return command; } - private static (Json.ExportFileEntry? entry, string? error) ProcessFile( + private static (Json.ExportFileEntry entry, string? _) ProcessFile( RpfFileEntry fileEntry, byte[] data, string fileOutputDir, diff --git a/CodeWalker.Cli/ExtractHandler.cs b/CodeWalker.Cli/ExtractHandler.cs index 754116e86..f7582a17d 100644 --- a/CodeWalker.Cli/ExtractHandler.cs +++ b/CodeWalker.Cli/ExtractHandler.cs @@ -114,8 +114,6 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => scanErrors ); - int grandTotalFileCount = (int)rpf.GrandTotalFileCount; - if (!options.Rpf.Json && options.DryRun) { Console.Error.WriteLine("Dry run mode - no files will be extracted"); @@ -306,10 +304,10 @@ jsonEntry with Json.ExtractResult result = new() { - Success = errors == 0, + Success = errors == 0 && scanErrors.Count == 0, RpfFile = options.Rpf.RpfPath, OutputDir = options.OutputPath ?? Directory.GetCurrentDirectory(), - TotalFiles = grandTotalFileCount, + TotalFiles = totalNonRpfFiles, Extracted = extracted, Skipped = skipped, Errors = errors, @@ -333,7 +331,7 @@ jsonEntry with ); } - return errors > 0 ? 1 : 0; + return (errors > 0 || scanErrors.Count > 0) ? 1 : 0; } catch (Exception ex) { diff --git a/CodeWalker.Cli/Gen9Handler.cs b/CodeWalker.Cli/Gen9Handler.cs index c6567516e..19306baf1 100644 --- a/CodeWalker.Cli/Gen9Handler.cs +++ b/CodeWalker.Cli/Gen9Handler.cs @@ -552,7 +552,22 @@ ref int errors string name = rfe.Name; string type = Path.GetExtension(rfe.NameLower); - byte[] dataIn = currentRpf.ExtractFile(rfe); + byte[]? dataIn = currentRpf.ExtractFile(rfe); + if (dataIn == null) + { + errors++; + string errorMsg = $"{rfe.Path} - failed to extract"; + errorMessages.Add(errorMsg); + files.Add( + new Json.Gen9FileEntry + { + Path = rfe.Path, + Status = "error", + Message = "Failed to extract file data", + } + ); + continue; + } dataIn = ResourceBuilder.Compress(dataIn); dataIn = ResourceBuilder.AddResourceHeader(rfe, dataIn); diff --git a/CodeWalker.Cli/Helpers/Filter.cs b/CodeWalker.Cli/Helpers/Filter.cs index b8f962d34..03e68a79c 100644 --- a/CodeWalker.Cli/Helpers/Filter.cs +++ b/CodeWalker.Cli/Helpers/Filter.cs @@ -77,9 +77,9 @@ private static bool MatchesGlob(string input, string pattern) if (!pattern.Contains('*', StringComparison.Ordinal) && !pattern.Contains('?', StringComparison.Ordinal)) { if (pattern.StartsWith('.')) - return input.EndsWith(pattern, System.StringComparison.Ordinal); + return input.EndsWith(pattern, StringComparison.Ordinal); else - return input.EndsWith($".{pattern}", System.StringComparison.Ordinal); + return input.EndsWith($".{pattern}", StringComparison.Ordinal); } Regex regex = RegexCache.GetOrAdd( diff --git a/CodeWalker.Cli/Helpers/ProgressBar.cs b/CodeWalker.Cli/Helpers/ProgressBar.cs index 1d35f195a..49bf1b2ed 100644 --- a/CodeWalker.Cli/Helpers/ProgressBar.cs +++ b/CodeWalker.Cli/Helpers/ProgressBar.cs @@ -88,7 +88,7 @@ private void Render(string? currentFile = null) try { double percent = _total > 0 ? (double)_current / _total : 0; - int filled = (int)(percent * _barWidth); + int filled = Math.Min((int)(percent * _barWidth), _barWidth); Console.SetCursorPosition(0, Console.CursorTop); Err.Write("["); diff --git a/CodeWalker.Cli/InspectHandler.cs b/CodeWalker.Cli/InspectHandler.cs index 1ebfa3cdd..f3573d0f6 100644 --- a/CodeWalker.Cli/InspectHandler.cs +++ b/CodeWalker.Cli/InspectHandler.cs @@ -83,7 +83,7 @@ Json.InspectResult ErrorResult(string[] errorMessages) => } // Find entry by normalized path - string normalizedPath = filePath.Replace('\\', '/').ToLowerInvariant(); + string normalizedPath = filePath.Replace('\\', '/'); RpfFileEntry? found = FindEntry(rpf, normalizedPath, options.Recursive); if (found == null) @@ -99,7 +99,25 @@ Json.InspectResult ErrorResult(string[] errorMessages) => string ext = Path.GetExtension(found.Name).ToLowerInvariant(); string fileType = RpfService.GetFileType(found); - // Build base result + // Extract type-specific metadata + int? resourceVersion = null; + long? systemSize = null; + long? graphicsSize = null; + long? uncompressedSize = null; + uint? encryptionType = null; + + if (found is RpfResourceFileEntry rfe) + { + resourceVersion = rfe.Version; + systemSize = rfe.SystemSize; + graphicsSize = rfe.GraphicsSize; + } + else if (found is RpfBinaryFileEntry bfe) + { + uncompressedSize = bfe.FileUncompressedSize; + encryptionType = bfe.EncryptionType; + } + Json.InspectResult result = new() { Success = scanErrors.Count == 0, @@ -112,14 +130,12 @@ Json.InspectResult ErrorResult(string[] errorMessages) => Extension = ext, NameHash = found.NameHash, ShortNameHash = found.ShortNameHash, - ResourceVersion = found is RpfResourceFileEntry rfe1 ? rfe1.Version : null, - SystemSize = found is RpfResourceFileEntry rfe2 ? rfe2.SystemSize : null, - GraphicsSize = found is RpfResourceFileEntry rfe3 ? rfe3.GraphicsSize : null, - UncompressedSize = found is RpfBinaryFileEntry bfe1 - ? bfe1.FileUncompressedSize - : null, - EncryptionType = found is RpfBinaryFileEntry bfe2 ? bfe2.EncryptionType : null, - Details = GetDetails(found, ext), + ResourceVersion = resourceVersion, + SystemSize = systemSize, + GraphicsSize = graphicsSize, + UncompressedSize = uncompressedSize, + EncryptionType = encryptionType, + Details = GetDetails(found, ext, options.Verbose), ErrorMessages = [.. scanErrors], }; @@ -134,7 +150,7 @@ Json.InspectResult ErrorResult(string[] errorMessages) => PrintTextResult(result, options); } - return 0; + return scanErrors.Count > 0 ? 1 : 0; } catch (Exception ex) { @@ -176,7 +192,7 @@ entry is RpfFileEntry fileEntry return null; } - private static object? GetDetails(RpfFileEntry entry, string ext) + private static object? GetDetails(RpfFileEntry entry, string ext, bool verbose) { try { @@ -194,8 +210,12 @@ entry is RpfFileEntry fileEntry _ => null, }; } - catch + catch (Exception ex) { + if (verbose) + { + Console.Error.WriteLine($"Warning: Failed to read details for {entry.Path}: {ex.Message}"); + } return null; } } diff --git a/CodeWalker.Cli/Json/DiffResult.cs b/CodeWalker.Cli/Json/DiffResult.cs index 481e583f4..99fff6380 100644 --- a/CodeWalker.Cli/Json/DiffResult.cs +++ b/CodeWalker.Cli/Json/DiffResult.cs @@ -50,9 +50,17 @@ internal sealed record DiffEntry [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public long? LeftSize { get; init; } + [JsonPropertyName("leftSizeFormatted")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? LeftSizeFormatted { get; init; } + [JsonPropertyName("rightSize")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public long? RightSize { get; init; } + + [JsonPropertyName("rightSizeFormatted")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? RightSizeFormatted { get; init; } } internal sealed record DiffSummary diff --git a/CodeWalker.Cli/Json/InspectResult.cs b/CodeWalker.Cli/Json/InspectResult.cs index 9bb28a798..27616e1b7 100644 --- a/CodeWalker.Cli/Json/InspectResult.cs +++ b/CodeWalker.Cli/Json/InspectResult.cs @@ -38,15 +38,15 @@ internal sealed record InspectResult : BaseResult [JsonPropertyName("systemSize")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int? SystemSize { get; init; } + public long? SystemSize { get; init; } [JsonPropertyName("graphicsSize")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int? GraphicsSize { get; init; } + public long? GraphicsSize { get; init; } [JsonPropertyName("uncompressedSize")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public uint? UncompressedSize { get; init; } + public long? UncompressedSize { get; init; } [JsonPropertyName("encryptionType")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] diff --git a/CodeWalker.Cli/Json/ListResult.cs b/CodeWalker.Cli/Json/ListResult.cs index 30c9c8809..8c68d717c 100644 --- a/CodeWalker.Cli/Json/ListResult.cs +++ b/CodeWalker.Cli/Json/ListResult.cs @@ -18,7 +18,7 @@ internal sealed record ListResult : BaseResult public required string TotalSizeFormatted { get; init; } [JsonPropertyName("nestedRpfCount")] - public required int NestedRpfCount { get; init; } + public required long NestedRpfCount { get; init; } [JsonPropertyName("files")] public required IReadOnlyList Files { get; init; } diff --git a/CodeWalker.Cli/Json/TreeResult.cs b/CodeWalker.Cli/Json/TreeResult.cs index 58cb67e19..757016002 100644 --- a/CodeWalker.Cli/Json/TreeResult.cs +++ b/CodeWalker.Cli/Json/TreeResult.cs @@ -15,7 +15,7 @@ internal sealed record TreeResult : BaseResult public required int TotalDirs { get; init; } [JsonPropertyName("root")] - public TreeNode? Root { get; init; } + public required TreeNode? Root { get; init; } } internal sealed record TreeNode diff --git a/CodeWalker.Cli/ListHandler.cs b/CodeWalker.Cli/ListHandler.cs index b3457dc8b..272342602 100644 --- a/CodeWalker.Cli/ListHandler.cs +++ b/CodeWalker.Cli/ListHandler.cs @@ -3,7 +3,6 @@ using System.CommandLine; using System.IO; using System.Text.Json; -using System.Threading.Tasks; using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; @@ -61,7 +60,7 @@ Json.ListResult ErrorResult(string[] errorMessages) => scanErrors ); - int nestedRpfCount = (int)rpf.GrandTotalRpfCount; + long nestedRpfCount = rpf.GrandTotalRpfCount; if (!options.Json) { @@ -75,29 +74,19 @@ Json.ListResult ErrorResult(string[] errorMessages) => options.Recursive ); - // Process entries in parallel, storing results by index to preserve order - (Json.FileEntry? jsonEntry, string? line, long size)[] results = new ( - Json.FileEntry?, - string?, - long - )[entries.Count]; - - Parallel.For( - 0, - entries.Count, - new ParallelOptions { MaxDegreeOfParallelism = options.Threads }, - i => - { - RpfFileEntry fileEntry = entries[i].entry; - long size = fileEntry.GetFileSize(); - string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); + long totalSize = 0; + List files = []; - Json.FileEntry? jsonEntry = null; - string? line = null; + foreach ((RpfFile _, RpfFileEntry fileEntry) in entries) + { + long size = fileEntry.GetFileSize(); + totalSize += size; + string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); - if (options.Json) - { - jsonEntry = new Json.FileEntry + if (options.Json) + { + files.Add( + new Json.FileEntry { Path = fileEntry.Path, Name = fileEntry.Name, @@ -105,42 +94,25 @@ Json.ListResult ErrorResult(string[] errorMessages) => SizeFormatted = options.SizeFormat.ToFormattedString(size), Type = RpfService.GetFileType(fileEntry), Extension = ext, - }; - } - else if (options.Verbose) - { - string sizeStr = options.SizeFormat.ToFormattedString(size).PadLeft(12); - line = $"{sizeStr} {fileEntry.Path}"; - } - else - { - line = fileEntry.Path; - } - - results[i] = (jsonEntry, line, size); + } + ); + } + else if (options.Verbose) + { + string sizeStr = options.SizeFormat.ToFormattedString(size).PadLeft(12); + Console.WriteLine($"{sizeStr} {fileEntry.Path}"); + } + else + { + Console.WriteLine(fileEntry.Path); } - ); - - // Output results sequentially to preserve order - long totalSize = 0; - int fileCount = 0; - List files = []; - foreach (var (jsonEntry, line, size) in results) - { - totalSize += size; - fileCount++; - - if (jsonEntry != null) - files.Add(jsonEntry); - else if (line != null) - Console.WriteLine(line); } Json.ListResult result = new() { Success = scanErrors.Count == 0, RpfFile = options.RpfPath, - TotalFiles = fileCount, + TotalFiles = entries.Count, TotalSize = totalSize, TotalSizeFormatted = options.SizeFormat.ToFormattedString(totalSize), NestedRpfCount = nestedRpfCount, @@ -158,11 +130,11 @@ Json.ListResult ErrorResult(string[] errorMessages) => { Console.Error.WriteLine(); Console.Error.WriteLine( - $"Total: {fileCount} files, {options.SizeFormat.ToFormattedString(totalSize)}" + $"Total: {entries.Count} files, {options.SizeFormat.ToFormattedString(totalSize)}" ); } - return 0; + return scanErrors.Count > 0 ? 1 : 0; } catch (Exception ex) { diff --git a/CodeWalker.Cli/PackHandler.cs b/CodeWalker.Cli/PackHandler.cs index e8da7671c..e7d209a23 100644 --- a/CodeWalker.Cli/PackHandler.cs +++ b/CodeWalker.Cli/PackHandler.cs @@ -59,7 +59,7 @@ public static Command CreateCommand() forceOption, progressOption, }; - commonOpts.AddTo(command); + commonOpts.AddTo(command, includeThreads: false); command.Aliases.Add("p"); command.SetAction(parseResult => @@ -127,6 +127,8 @@ Json.PackResult ErrorResult(string[] errorMessages) => return RpfService.ReportError(exeError, options.Common.Json, ErrorResult([])); } + bool previousGen9 = RpfManager.IsGen9; + RpfManager.IsGen9 = options.Gen9; try { // Count files for progress bar @@ -232,6 +234,10 @@ ref errors options.Common.Verbose ? ex.StackTrace : null ); } + finally + { + RpfManager.IsGen9 = previousGen9; + } } private static void AddDirectoryContents( diff --git a/CodeWalker.Cli/RpfService.cs b/CodeWalker.Cli/RpfService.cs index f91886085..df85a80db 100644 --- a/CodeWalker.Cli/RpfService.cs +++ b/CodeWalker.Cli/RpfService.cs @@ -99,17 +99,20 @@ private static void CollectFilesRecursive( List<(RpfFile, RpfFileEntry)> files ) { - foreach (RpfEntry entry in rpf.AllEntries) + if (rpf.AllEntries != null) { - if (entry is RpfFileEntry fileEntry) + foreach (RpfEntry entry in rpf.AllEntries) { - if (entry.NameLower.EndsWith(".rpf", StringComparison.Ordinal)) - continue; + if (entry is RpfFileEntry fileEntry) + { + if (entry.NameLower.EndsWith(".rpf", StringComparison.Ordinal)) + continue; - if (!Filter.Matches(entry.Path, filters)) - continue; + if (!Filter.Matches(entry.Path, filters)) + continue; - files.Add((rpf, fileEntry)); + files.Add((rpf, fileEntry)); + } } } @@ -134,11 +137,14 @@ public static int CountNonRpfFiles(RpfFile rpf, bool recursive) private static void CountNonRpfFilesRecursive(RpfFile rpf, bool recursive, ref int count) { - foreach (RpfEntry entry in rpf.AllEntries) + if (rpf.AllEntries != null) { - if (entry is RpfFileEntry && !entry.NameLower.EndsWith(".rpf", StringComparison.Ordinal)) + foreach (RpfEntry entry in rpf.AllEntries) { - count++; + if (entry is RpfFileEntry && !entry.NameLower.EndsWith(".rpf", StringComparison.Ordinal)) + { + count++; + } } } diff --git a/CodeWalker.Cli/SearchHandler.cs b/CodeWalker.Cli/SearchHandler.cs index 358e115f8..4534971d0 100644 --- a/CodeWalker.Cli/SearchHandler.cs +++ b/CodeWalker.Cli/SearchHandler.cs @@ -128,9 +128,9 @@ out uint hash { // Substring match patternType = "substring"; - string lowerPattern = pattern.ToLowerInvariant(); + string normalizedPattern = pattern.Replace('\\', '/'); matcher = entry => - entry.Path?.Replace('\\', '/').Contains(lowerPattern, StringComparison.OrdinalIgnoreCase) == true; + entry.Path?.Replace('\\', '/').Contains(normalizedPattern, StringComparison.OrdinalIgnoreCase) == true; } // Match in parallel @@ -218,7 +218,7 @@ out uint hash ); } - return 0; + return scanErrors.Count > 0 ? 1 : 0; } catch (Exception ex) { @@ -233,7 +233,7 @@ out uint hash private static bool HasGlobChars(string s) { - return s.Contains('*', StringComparison.Ordinal) || s.Contains('?', StringComparison.Ordinal) || s.Contains('[', StringComparison.Ordinal); + return s.Contains('*', StringComparison.Ordinal) || s.Contains('?', StringComparison.Ordinal); } private static void CollectAllEntries(RpfFile rpf, bool recursive, List entries) diff --git a/CodeWalker.Cli/StatHandler.cs b/CodeWalker.Cli/StatHandler.cs index e657f77a1..e42976d67 100644 --- a/CodeWalker.Cli/StatHandler.cs +++ b/CodeWalker.Cli/StatHandler.cs @@ -76,6 +76,7 @@ Json.StatResult ErrorResult(string[] errorMessages) => options.Recursive ); + long totalSize = 0; int resourceCount = 0; int binaryCount = 0; long compressedSize = 0; @@ -86,6 +87,7 @@ Json.StatResult ErrorResult(string[] errorMessages) => foreach ((RpfFile _, RpfFileEntry fileEntry) in entries) { long size = fileEntry.GetFileSize(); + totalSize += size; string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); if (string.IsNullOrEmpty(ext)) ext = "(none)"; @@ -117,8 +119,6 @@ Json.StatResult ErrorResult(string[] errorMessages) => uncompressedSize += bfe.FileUncompressedSize; } } - - long totalSize = entries.Sum(e => e.entry.GetFileSize()); double compressionRatio = uncompressedSize > 0 ? (double)compressedSize / uncompressedSize : 0; @@ -184,12 +184,12 @@ .. extStats if (uncompressedSize > 0) { Console.Error.WriteLine( - $"Compression: {options.SizeFormat.ToFormattedString(compressedSize)} / {options.SizeFormat.ToFormattedString(uncompressedSize)} ({compressionRatio:P1})" + $"Compression: {options.SizeFormat.ToFormattedString(compressedSize)} / {options.SizeFormat.ToFormattedString(uncompressedSize)} ({compressionRatio:P1} of original)" ); } } - return 0; + return scanErrors.Count > 0 ? 1 : 0; } catch (Exception ex) { diff --git a/CodeWalker.Cli/TreeHandler.cs b/CodeWalker.Cli/TreeHandler.cs index 2c7dae530..b51208e5f 100644 --- a/CodeWalker.Cli/TreeHandler.cs +++ b/CodeWalker.Cli/TreeHandler.cs @@ -61,6 +61,7 @@ Json.TreeResult ErrorResult(string[] errorMessages) => RpfFile = options.Rpf.RpfPath, TotalFiles = 0, TotalDirs = 0, + Root = null, ErrorMessages = errorMessages, }; @@ -101,7 +102,7 @@ ref totalDirs Json.TreeResult result = new() { - Success = true, + Success = scanErrors.Count == 0, RpfFile = options.Rpf.RpfPath, TotalFiles = totalFiles, TotalDirs = totalDirs, @@ -122,7 +123,7 @@ ref totalDirs Console.Error.WriteLine($"{totalDirs} directories, {totalFiles} files"); } - return 0; + return scanErrors.Count > 0 ? 1 : 0; } catch (Exception ex) { diff --git a/CodeWalker.Cli/ValidateHandler.cs b/CodeWalker.Cli/ValidateHandler.cs index 3132f52e1..5a880bffd 100644 --- a/CodeWalker.Cli/ValidateHandler.cs +++ b/CodeWalker.Cli/ValidateHandler.cs @@ -105,7 +105,7 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => new ParallelOptions { MaxDegreeOfParallelism = options.Rpf.Threads }, i => { - (RpfFile sourceRpf, RpfFileEntry fileEntry) = entries[i]; + (_, RpfFileEntry fileEntry) = entries[i]; string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); try @@ -225,7 +225,7 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => ); } - return errors > 0 ? 1 : 0; + return (errors > 0 || scanErrors.Count > 0) ? 1 : 0; } catch (Exception ex) { From 14e676d0ec29b674db9947d766a34a5b3b6bae18 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:26:32 +0200 Subject: [PATCH 10/45] chore(cli): add the launch and task configurations Debugging the CLI from VS Code needed the launch configuration written by hand every time, since the solution's own configurations all point at the WinForms projects. --- .vscode/launch.json | 35 +++++++++++++++++++++++++++++++++ .vscode/tasks.json | 48 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..60b31bceb --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "CLI (net48)", + "type": "clr", + "request": "launch", + "preLaunchTask": "build-cli", + "program": "${workspaceFolder}/CodeWalker.Cli/bin/Debug/net48/CodeWalker.Cli.exe", + "args": [], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal" + }, + { + "name": "CLI (net8.0)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-cli", + "program": "${workspaceFolder}/CodeWalker.Cli/bin/Debug/net8.0/CodeWalker.Cli.dll", + "args": [], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal" + }, + { + "name": "CLI (net10.0)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-cli", + "program": "${workspaceFolder}/CodeWalker.Cli/bin/Debug/net10.0/CodeWalker.Cli.dll", + "args": [], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..b556485fe --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,48 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build-cli", + "type": "process", + "command": "dotnet", + "args": ["build", "CodeWalker.Cli/CodeWalker.Cli.csproj"], + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": "$msCompile", + "presentation": { + "reveal": "silent", + "revealProblems": "onProblem" + } + }, + { + "label": "rebuild-cli", + "type": "process", + "command": "dotnet", + "args": [ + "build", + "CodeWalker.Cli/CodeWalker.Cli.csproj", + "--no-incremental" + ], + "group": "build", + "problemMatcher": "$msCompile" + }, + { + "label": "clean-cli", + "type": "process", + "command": "dotnet", + "args": ["clean", "CodeWalker.Cli/CodeWalker.Cli.csproj"], + "group": "build", + "problemMatcher": "$msCompile" + }, + { + "label": "format-cli", + "type": "process", + "command": "dotnet", + "args": ["format", "CodeWalker.Cli/"], + "group": "build", + "problemMatcher": [] + } + ] +} From 464673b5fc6d9378c1c986b3304d06d507a963a1 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:26:32 +0200 Subject: [PATCH 11/45] test(cli): add the unit and fuzz test project Nothing was tested. CodeWalker.Cli.Tests builds the same sources with TESTING defined and xunit v3 on top, so the internals are reachable without making them public. Coverage starts with the pieces that are pure functions and easy to get wrong: the glob translation, the size formatting in both unit systems, the progress bar's rendering and throttling, the option parsing, and the net48 string polyfills, which are the only place where the three target frameworks can disagree about behaviour. The polyfill fuzz tests compare each polyfill against the framework method it stands in for. The polyfills were compiled out on anything newer than netstandard2.1, which meant net8.0 and net10.0 could not exercise them at all; the guard excludes netcoreapp2.1 and up and admits them under TESTING. The Json records are excluded from coverage. They are property bags with no branches. --- .vscode/tasks.json | 21 +- CodeWalker.Cli/CodeWalker.Cli.Tests.csproj | 38 ++ CodeWalker.Cli/CodeWalker.Cli.csproj | 26 +- CodeWalker.Cli/CommonOptions.cs | 2 + CodeWalker.Cli/Compiler.cs | 65 --- CodeWalker.Cli/Directory.Build.props | 35 ++ CodeWalker.Cli/ExportService.cs | 241 ++++++---- CodeWalker.Cli/ExtractHandler.cs | 31 +- CodeWalker.Cli/Gen9Handler.cs | 2 +- CodeWalker.Cli/Helpers/Filter.cs | 4 - CodeWalker.Cli/Helpers/ProgressBar.cs | 56 ++- CodeWalker.Cli/Helpers/SizeFormat.cs | 3 +- CodeWalker.Cli/InspectHandler.cs | 36 +- CodeWalker.Cli/Json/DiffResult.cs | 4 + CodeWalker.Cli/Json/ExportResult.cs | 3 + CodeWalker.Cli/Json/ExtractResult.cs | 2 + CodeWalker.Cli/Json/FileEntry.cs | 2 + CodeWalker.Cli/Json/Gen9Result.cs | 3 + CodeWalker.Cli/Json/HashResult.cs | 3 + CodeWalker.Cli/Json/InspectResult.cs | 17 + CodeWalker.Cli/Json/ListResult.cs | 2 + CodeWalker.Cli/Json/PackResult.cs | 2 + CodeWalker.Cli/Json/SearchResult.cs | 3 + CodeWalker.Cli/Json/StatResult.cs | 3 + CodeWalker.Cli/Json/TreeResult.cs | 3 + CodeWalker.Cli/Json/ValidateResult.cs | 3 + CodeWalker.Cli/Polyfills.cs | 141 ++++++ CodeWalker.Cli/Program.cs | 2 + CodeWalker.Cli/RpfOptions.cs | 2 + CodeWalker.Cli/SearchHandler.cs | 4 - CodeWalker.Cli/StatHandler.cs | 4 +- CodeWalker.Cli/Tests/CommonOptionsTests.cs | 91 ++++ CodeWalker.Cli/Tests/ExportServiceTests.cs | 408 +++++++++++++++++ CodeWalker.Cli/Tests/Helpers/FilterTests.cs | 91 ++++ .../Tests/Helpers/ProgressBarTests.cs | 285 ++++++++++++ .../Tests/Helpers/SizeFormatTests.cs | 85 ++++ CodeWalker.Cli/Tests/PolyfillsTests.cs | 237 ++++++++++ CodeWalker.Cli/Tests/RpfOptionsTests.cs | 75 +++ CodeWalker.Cli/Tests/RpfServiceTests.cs | 431 ++++++++++++++++++ CodeWalker.sln | 16 + 40 files changed, 2230 insertions(+), 252 deletions(-) create mode 100644 CodeWalker.Cli/CodeWalker.Cli.Tests.csproj create mode 100644 CodeWalker.Cli/Directory.Build.props create mode 100644 CodeWalker.Cli/Polyfills.cs create mode 100644 CodeWalker.Cli/Tests/CommonOptionsTests.cs create mode 100644 CodeWalker.Cli/Tests/ExportServiceTests.cs create mode 100644 CodeWalker.Cli/Tests/Helpers/FilterTests.cs create mode 100644 CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs create mode 100644 CodeWalker.Cli/Tests/Helpers/SizeFormatTests.cs create mode 100644 CodeWalker.Cli/Tests/PolyfillsTests.cs create mode 100644 CodeWalker.Cli/Tests/RpfOptionsTests.cs create mode 100644 CodeWalker.Cli/Tests/RpfServiceTests.cs diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b556485fe..f589760a8 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -40,9 +40,28 @@ "label": "format-cli", "type": "process", "command": "dotnet", - "args": ["format", "CodeWalker.Cli/"], + "args": ["format", "CodeWalker.Cli/CodeWalker.Cli.csproj"], "group": "build", "problemMatcher": [] + }, + { + "label": "format-cli-tests", + "type": "process", + "command": "dotnet", + "args": ["format", "CodeWalker.Cli/CodeWalker.Cli.Tests.csproj"], + "group": "build", + "problemMatcher": [] + }, + { + "label": "test-cli", + "type": "process", + "command": "dotnet", + "args": ["test", "CodeWalker.Cli/CodeWalker.Cli.Tests.csproj"], + "group": { + "kind": "test", + "isDefault": true + }, + "problemMatcher": "$msCompile" } ] } diff --git a/CodeWalker.Cli/CodeWalker.Cli.Tests.csproj b/CodeWalker.Cli/CodeWalker.Cli.Tests.csproj new file mode 100644 index 000000000..a84d9a945 --- /dev/null +++ b/CodeWalker.Cli/CodeWalker.Cli.Tests.csproj @@ -0,0 +1,38 @@ + + + + CodeWalker.Cli + true + $(DefineConstants);TESTING + + $(NoWarn);CA1707;CS1591 + + true + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + $(WarningsAsErrors);CS8509 + + + diff --git a/CodeWalker.Cli/CodeWalker.Cli.csproj b/CodeWalker.Cli/CodeWalker.Cli.csproj index c6bd4c60b..0dc5bd6da 100644 --- a/CodeWalker.Cli/CodeWalker.Cli.csproj +++ b/CodeWalker.Cli/CodeWalker.Cli.csproj @@ -1,10 +1,6 @@ + - Exe - net48;net8.0;net10.0 - latest - enable - disable dexyfex dexyfex software dexyfex @@ -12,17 +8,8 @@ - - - + + @@ -43,7 +30,8 @@ true - - - + + $(WarningsAsErrors);CS8509 + + diff --git a/CodeWalker.Cli/CommonOptions.cs b/CodeWalker.Cli/CommonOptions.cs index de1d2e7cb..071cf6641 100644 --- a/CodeWalker.Cli/CommonOptions.cs +++ b/CodeWalker.Cli/CommonOptions.cs @@ -1,11 +1,13 @@ using System; using System.CommandLine; +using System.Diagnostics.CodeAnalysis; using System.IO; using CodeWalker.Cli.Helpers; namespace CodeWalker.Cli; +[ExcludeFromCodeCoverage] internal sealed record CommonOptions { public required string ExePath { get; init; } diff --git a/CodeWalker.Cli/Compiler.cs b/CodeWalker.Cli/Compiler.cs index 88849b57e..211c8c004 100644 --- a/CodeWalker.Cli/Compiler.cs +++ b/CodeWalker.Cli/Compiler.cs @@ -1,14 +1,5 @@ -// Source - https://stackoverflow.com/a/74447498 -// Posted by Matthew Watson -// Retrieved 2026-02-06, License - CC BY-SA 4.0 - #pragma warning disable IDE0130 // Namespace does not match folder structure -#if !NETCOREAPP -using System; -using System.Text; -#endif - #if !NET5_0_OR_GREATER using System.ComponentModel; #endif @@ -55,60 +46,4 @@ internal sealed class SetsRequiredMembersAttribute : Attribute { } #endif } -namespace CodeWalker.Cli.Polyfills -{ -#if !NETCOREAPP - internal static class StringExtensions - { - public static bool Contains(this string s, char value) - { - return s.IndexOf(value) >= 0; - } - - public static bool Contains(this string s, char value, StringComparison comparisonType) - { - return s.IndexOf(value.ToString(), comparisonType) >= 0; - } - - public static bool Contains(this string s, string value, StringComparison comparisonType) - { - return s.IndexOf(value, comparisonType) >= 0; - } - - public static bool StartsWith(this string s, char value) - { - return s.Length > 0 && s[0] == value; - } - - private static string ReplaceInternal( - this string s, - string oldValue, - string? newValue, - StringComparison comparisonType - ) - { - StringBuilder sb = new(); - int start = 0; - int index; - while ((index = s.IndexOf(oldValue, start, comparisonType)) >= 0) - { - sb.Append(s, start, index - start); - if (newValue != null) - sb.Append(newValue); - start = index + oldValue.Length; - } - sb.Append(s, start, s.Length - start); - return sb.ToString(); - } - - public static string Replace(this string s, string oldValue, string? newValue, StringComparison comparisonType) => - comparisonType switch - { - StringComparison.Ordinal => s.Replace(oldValue, newValue), - _ => s.ReplaceInternal(oldValue, newValue, comparisonType), - }; - } -#endif -} - #pragma warning restore IDE0130 // Namespace does not match folder structure diff --git a/CodeWalker.Cli/Directory.Build.props b/CodeWalker.Cli/Directory.Build.props new file mode 100644 index 000000000..9d80d396d --- /dev/null +++ b/CodeWalker.Cli/Directory.Build.props @@ -0,0 +1,35 @@ + + + + Exe + net48;net8.0;net10.0 + net8.0;net10.0 + latest + enable + disable + + obj/$(MSBuildProjectName)/ + bin/$(MSBuildProjectName)/ + + $(DefaultItemExcludes);obj/**;bin/** + + + + + + + + + + + + + diff --git a/CodeWalker.Cli/ExportService.cs b/CodeWalker.Cli/ExportService.cs index 2086e252b..d2a16ec77 100644 --- a/CodeWalker.Cli/ExportService.cs +++ b/CodeWalker.Cli/ExportService.cs @@ -27,6 +27,112 @@ bool noOverwrite internal static class ExportService { + internal readonly record struct ExportAggregation + { + public required int Exported { get; init; } + public required int Skipped { get; init; } + public required int Errors { get; init; } + public required IReadOnlyList Files { get; init; } + public required IReadOnlyList ErrorMessages { get; init; } + } + + internal static (Json.ExportFileEntry? entry, string? error) ProcessSingleFile( + RpfFileEntry fileEntry, + byte[]? data, + string outputDir, + bool dryRun, + bool noOverwrite, + ExportFileProcessor processor + ) + { + string relativePath = + Path.GetDirectoryName(fileEntry.Path) + ?.Replace('\\', Path.DirectorySeparatorChar) + ?? ""; + + string fileOutputDir = Path.Combine(outputDir, relativePath); + + if (dryRun) + { + return ( + new Json.ExportFileEntry + { + Path = fileEntry.Path, + Name = fileEntry.Name, + OutputFiles = 0, + Status = "dry_run", + }, + null + ); + } + + if (data == null) + { + return (null, $"Failed to extract: {fileEntry.Path}"); + } + + (Json.ExportFileEntry? entry, string? error) = processor( + fileEntry, + data, + fileOutputDir, + noOverwrite + ); + + if (error != null) + { + return (entry, error); + } + + if (entry != null) + { + return (entry, null); + } + + return (null, $"No result for: {fileEntry.Path}"); + } + + internal static ExportAggregation AggregateResults( + (Json.ExportFileEntry? jsonEntry, string? errorMessage)[] results, + IReadOnlyList scanErrors, + int filterSkipped + ) + { + int exported = 0; + int skipped = 0; + int errors = 0; + List files = []; + List errorMessages = [.. scanErrors]; + + foreach ((Json.ExportFileEntry? jsonEntry, string? errorMessage) in results) + { + if (errorMessage == null && jsonEntry?.Status is "exported" or "dry_run") + exported++; + + if (jsonEntry?.Status is "unsupported" or "skipped") + skipped++; + + if (jsonEntry != null) + files.Add(jsonEntry); + + if (errorMessage != null) + { + errors++; + errorMessages.Add(errorMessage); + } + } + + skipped += filterSkipped; + + return new ExportAggregation + { + Exported = exported, + Skipped = skipped, + Errors = errors, + Files = files, + ErrorMessages = errorMessages, + }; + } + public static int Execute( ExportOptions options, string format, @@ -92,11 +198,8 @@ Json.ExportResult ErrorResult(string[] errorMessages) => int totalNonRpfFiles = RpfService.CountNonRpfFiles(rpf, options.Rpf.Recursive); int filterSkipped = totalNonRpfFiles - filesToExport.Count; - (bool success, Json.ExportFileEntry? jsonEntry, string? errorMessage)[] results = new ( - bool, - Json.ExportFileEntry?, - string? - )[filesToExport.Count]; + (Json.ExportFileEntry? jsonEntry, string? errorMessage)[] results = + new (Json.ExportFileEntry?, string?)[filesToExport.Count]; object consoleLock = new(); @@ -116,77 +219,46 @@ Json.ExportResult ErrorResult(string[] errorMessages) => (RpfFile sourceRpf, RpfFileEntry fileEntry) = filesToExport[i]; try { - string relativePath = - Path.GetDirectoryName(fileEntry.Path) - ?.Replace('\\', Path.DirectorySeparatorChar) - ?? ""; + byte[]? data = options.DryRun + ? null + : sourceRpf.ExtractFile(fileEntry); - string fileOutputDir = Path.Combine(outputDir, relativePath); + (Json.ExportFileEntry? entry, string? error) result = ProcessSingleFile( + fileEntry, + data, + outputDir, + options.DryRun, + options.NoOverwrite, + processor + ); - if (options.DryRun) + results[i] = result; + + if ( + result.entry != null + && options.Rpf.Verbose + && !options.Rpf.Json + && !options.Progress + ) { - if (options.Rpf.Verbose && !options.Rpf.Json) + if (options.DryRun) { lock (consoleLock) { - Console.WriteLine($"Would export: {fileEntry.Path}"); + Console.WriteLine( + $"Would export: {fileEntry.Path}" + ); } } - results[i] = ( - true, - new Json.ExportFileEntry - { - Path = fileEntry.Path, - Name = fileEntry.Name, - OutputFiles = 0, - Status = "dry_run", - }, - null - ); - progress.Increment(fileEntry.Path); - return; - } - - byte[]? data = sourceRpf.ExtractFile(fileEntry); - if (data == null) - { - results[i] = (false, null, $"Failed to extract: {fileEntry.Path}"); - progress.Increment(); - return; - } - - (Json.ExportFileEntry? entry, string? error) = processor( - fileEntry, - data, - fileOutputDir, - options.NoOverwrite - ); - - if (error != null) - { - results[i] = (false, entry, error); - } - else if (entry != null) - { - if ( - options.Rpf.Verbose - && !options.Rpf.Json - && !options.Progress - && entry.Status == "exported" - ) + else if (result.entry.Status == "exported") { lock (consoleLock) { Console.Error.WriteLine( - $"Exported: {fileEntry.Path} -> {entry.OutputFiles} file(s)" + $"Exported: {fileEntry.Path} -> {result.entry.OutputFiles} file(s)" ); } } - results[i] = (true, entry, null); - } - else - { - results[i] = (false, null, $"No result for: {fileEntry.Path}"); } progress.Increment(fileEntry.Path); @@ -203,7 +275,6 @@ Json.ExportResult ErrorResult(string[] errorMessages) => } } results[i] = ( - false, null, $"Error exporting {fileEntry.Path}: {ex.Message}" ); @@ -213,51 +284,27 @@ Json.ExportResult ErrorResult(string[] errorMessages) => ); } - int exported = 0; - int skipped = 0; - int errors = 0; - List files = []; - List errorMessages = new(scanErrors); - - foreach (var (success, jsonEntry, errorMessage) in results) - { - if (success && jsonEntry?.Status is "exported" or "dry_run") - exported++; - - if (jsonEntry?.Status is "unsupported" or "skipped") - skipped++; - - if (jsonEntry != null) - files.Add(jsonEntry); - - if (!success && errorMessage != null) - { - errors++; - errorMessages.Add(errorMessage); - } - } - - skipped += filterSkipped; + ExportAggregation agg = AggregateResults(results, scanErrors, filterSkipped); - Json.ExportResult result = new() + Json.ExportResult jsonResult = new() { - Success = errors == 0 && scanErrors.Count == 0, + Success = agg.Errors == 0 && scanErrors.Count == 0, RpfFile = options.Rpf.RpfPath, OutputDir = options.OutputPath, Format = format, TotalFiles = totalNonRpfFiles, - Exported = exported, - Skipped = skipped, - Errors = errors, + Exported = agg.Exported, + Skipped = agg.Skipped, + Errors = agg.Errors, DryRun = options.DryRun, - Files = [.. files], - ErrorMessages = [.. errorMessages], + Files = agg.Files, + ErrorMessages = agg.ErrorMessages, }; if (options.Rpf.Json) { Console.WriteLine( - JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + JsonSerializer.Serialize(jsonResult, RpfService.JsonSerializerOptions) ); } else @@ -265,11 +312,11 @@ Json.ExportResult ErrorResult(string[] errorMessages) => Console.Error.WriteLine(); string action = options.DryRun ? "would be exported" : "exported"; Console.Error.WriteLine( - $"{summaryLabel} export complete: {exported} files {action}, {skipped} skipped, {errors} errors" + $"{summaryLabel} export complete: {agg.Exported} files {action}, {agg.Skipped} skipped, {agg.Errors} errors" ); } - return (errors > 0 || scanErrors.Count > 0) ? 1 : 0; + return (agg.Errors > 0 || scanErrors.Count > 0) ? 1 : 0; } catch (Exception ex) { diff --git a/CodeWalker.Cli/ExtractHandler.cs b/CodeWalker.Cli/ExtractHandler.cs index f7582a17d..c45dda511 100644 --- a/CodeWalker.Cli/ExtractHandler.cs +++ b/CodeWalker.Cli/ExtractHandler.cs @@ -9,10 +9,6 @@ using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; -#if !NETCOREAPP -using CodeWalker.Cli.Polyfills; -#endif - namespace CodeWalker.Cli; internal sealed record ExtractOptions @@ -139,11 +135,8 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => int overwriteSkipped = 0; // Process files in parallel, storing results by index to preserve order - (bool success, Json.FileEntry? jsonEntry, string? errorMessage)[] results = new ( - bool, - Json.FileEntry?, - string? - )[filesToExtract.Count]; + (Json.FileEntry? jsonEntry, string? errorMessage)[] results = + new (Json.FileEntry?, string?)[filesToExtract.Count]; object consoleLock = new(); @@ -192,7 +185,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => Console.WriteLine($"Would extract: {fileEntry.Path}"); } } - results[i] = (true, jsonEntry with { Status = "dry_run" }, null); + results[i] = (jsonEntry with { Status = "dry_run" }, null); } else if (options.NoOverwrite && File.Exists(outputPath)) { @@ -206,7 +199,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => ); } } - results[i] = (false, jsonEntry with { Status = "skipped" }, null); + results[i] = (jsonEntry with { Status = "skipped" }, null); } else { @@ -228,11 +221,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => { File.WriteAllBytes(outputPath, data); results[i] = ( - true, - jsonEntry with - { - Status = "extracted", - }, + jsonEntry with { Status = "extracted" }, null ); } @@ -248,7 +237,6 @@ jsonEntry with } } results[i] = ( - false, null, $"Failed to extract: {fileEntry.Path}" ); @@ -269,7 +257,6 @@ jsonEntry with } } results[i] = ( - false, null, $"Error extracting {fileEntry.Path}: {ex.Message}" ); @@ -283,17 +270,17 @@ jsonEntry with int extracted = 0; int errors = 0; List files = []; - List errorMessages = new(scanErrors); + List errorMessages = [.. scanErrors]; - foreach (var (success, jsonEntry, errorMessage) in results) + foreach ((Json.FileEntry? jsonEntry, string? errorMessage) in results) { - if (success) + if (jsonEntry?.Status is "extracted" or "dry_run") extracted++; if (jsonEntry != null) files.Add(jsonEntry); - if (!success && errorMessage != null) + if (errorMessage != null) { errors++; errorMessages.Add(errorMessage); diff --git a/CodeWalker.Cli/Gen9Handler.cs b/CodeWalker.Cli/Gen9Handler.cs index 19306baf1..c6b5d8c25 100644 --- a/CodeWalker.Cli/Gen9Handler.cs +++ b/CodeWalker.Cli/Gen9Handler.cs @@ -335,7 +335,7 @@ out bool wasConverted ); // Aggregate non-RPF results - foreach (var (entry, error) in nonRpfResults) + foreach ((Json.Gen9FileEntry entry, string? error) in nonRpfResults) { files.Add(entry); switch (entry.Status) diff --git a/CodeWalker.Cli/Helpers/Filter.cs b/CodeWalker.Cli/Helpers/Filter.cs index 03e68a79c..4bda4880d 100644 --- a/CodeWalker.Cli/Helpers/Filter.cs +++ b/CodeWalker.Cli/Helpers/Filter.cs @@ -3,10 +3,6 @@ using System.Collections.Generic; using System.Text.RegularExpressions; -#if !NETCOREAPP -using CodeWalker.Cli.Polyfills; -#endif - namespace CodeWalker.Cli.Helpers; /// diff --git a/CodeWalker.Cli/Helpers/ProgressBar.cs b/CodeWalker.Cli/Helpers/ProgressBar.cs index 49bf1b2ed..89ffd4de1 100644 --- a/CodeWalker.Cli/Helpers/ProgressBar.cs +++ b/CodeWalker.Cli/Helpers/ProgressBar.cs @@ -13,26 +13,36 @@ internal sealed class ProgressBar : IDisposable private int _current; private readonly bool _enabled; private readonly int _barWidth = 40; + private readonly TextWriter _writer; + private readonly bool _ownsConsole; + private readonly int _windowWidth; private DateTime _lastUpdate = DateTime.MinValue; private readonly object _lock = new(); - private static TextWriter Err => Console.Error; /// /// Initializes a new instance of the ProgressBar class. /// /// Total number of items to process. /// Whether to enable the progress bar display. - public ProgressBar(int total, bool enabled) + /// Optional text writer for output. When null, writes to stderr with console cursor control. + /// Terminal width used for padding and truncation when a custom writer is provided. + public ProgressBar(int total, bool enabled, TextWriter? writer = null, int windowWidth = 120) { _total = total; - _enabled = enabled && total > 0 && !Console.IsErrorRedirected; + _writer = writer ?? Console.Error; + _ownsConsole = writer is null; + _windowWidth = windowWidth; + _enabled = enabled && total > 0 && (!_ownsConsole || !Console.IsErrorRedirected); if (_enabled) { - try + if (_ownsConsole) { - Console.CursorVisible = false; + try + { + Console.CursorVisible = false; + } + catch { } } - catch { } Render(); } } @@ -89,32 +99,41 @@ private void Render(string? currentFile = null) { double percent = _total > 0 ? (double)_current / _total : 0; int filled = Math.Min((int)(percent * _barWidth), _barWidth); + int winWidth = _ownsConsole ? Console.WindowWidth : _windowWidth; + + if (_ownsConsole) + Console.SetCursorPosition(0, Console.CursorTop); - Console.SetCursorPosition(0, Console.CursorTop); - Err.Write("["); - Err.Write(new string('=', filled)); + _writer.Write("["); + _writer.Write(new string('=', filled)); if (filled < _barWidth) { - Err.Write(">"); - Err.Write(new string(' ', _barWidth - filled - 1)); + _writer.Write(">"); + _writer.Write(new string(' ', _barWidth - filled - 1)); } - Err.Write($"] {percent,6:P0} ({_current}/{_total})"); + + string stats = $"] {percent,6:P0} ({_current}/{_total})"; + _writer.Write(stats); + + int written = 1 + _barWidth + stats.Length; if (!string.IsNullOrEmpty(currentFile)) { - int maxLen = Math.Max(10, Console.WindowWidth - _barWidth - 30); + int maxLen = Math.Max(10, winWidth - _barWidth - 30); string displayFile = currentFile!.Length > maxLen ? $"...{currentFile[(currentFile.Length - maxLen + 3)..]}" : currentFile; - Err.Write($" {displayFile}"); + string fileText = $" {displayFile}"; + _writer.Write(fileText); + written += fileText.Length; } // Clear rest of line - int remaining = Console.WindowWidth - Console.CursorLeft - 1; + int remaining = winWidth - written - 1; if (remaining > 0) { - Err.Write(new string(' ', remaining)); + _writer.Write(new string(' ', remaining)); } } catch (Exception ex) @@ -133,8 +152,9 @@ public void Dispose() { try { - Err.WriteLine(); - Console.CursorVisible = true; + _writer.WriteLine(); + if (_ownsConsole) + Console.CursorVisible = true; } catch (Exception ex) when (ex is IOException or InvalidOperationException or SecurityException) diff --git a/CodeWalker.Cli/Helpers/SizeFormat.cs b/CodeWalker.Cli/Helpers/SizeFormat.cs index 3ef83ed58..c1e2df33c 100644 --- a/CodeWalker.Cli/Helpers/SizeFormat.cs +++ b/CodeWalker.Cli/Helpers/SizeFormat.cs @@ -46,12 +46,13 @@ public static string ToFormattedString(this SizeFormat format, long bytes) double divisor = format.GetDivisor(); string[] suffixes = format.GetSuffixes(); int i = 0; - double size = bytes; + double size = Math.Abs((double)bytes); while (size >= divisor && i < suffixes.Length - 1) { size /= divisor; i++; } + if (bytes < 0) size = -size; return $"{size:0.##} {suffixes[i]}"; } } diff --git a/CodeWalker.Cli/InspectHandler.cs b/CodeWalker.Cli/InspectHandler.cs index f3573d0f6..8be561e1c 100644 --- a/CodeWalker.Cli/InspectHandler.cs +++ b/CodeWalker.Cli/InspectHandler.cs @@ -226,9 +226,9 @@ entry is RpfFileEntry fileEntry if (file?.TextureDict?.Textures?.data_items == null) return null; - var textures = file.TextureDict.Textures.data_items; + Texture[] textures = file.TextureDict.Textures.data_items; List infos = []; - foreach (var tex in textures) + foreach (Texture tex in textures) { if (tex == null) continue; @@ -263,9 +263,9 @@ entry is RpfFileEntry fileEntry if (file?.DrawableDict?.Drawables?.data_items == null) return null; - var drawables = file.DrawableDict.Drawables.data_items; + Drawable?[] drawables = file.DrawableDict.Drawables.data_items; List infos = []; - foreach (var d in drawables) + foreach (Drawable? d in drawables) { if (d == null) continue; @@ -273,11 +273,11 @@ entry is RpfFileEntry fileEntry long tris = 0; if (d.AllModels != null) { - foreach (var model in d.AllModels) + foreach (DrawableModel? model in d.AllModels) { if (model?.Geometries == null) continue; - foreach (var geom in model.Geometries) + foreach (DrawableGeometry? geom in model.Geometries) { verts += geom.VerticesCount; tris += geom.TrianglesCount; @@ -359,7 +359,7 @@ entry is RpfFileEntry fileEntry int mloCount = 0; List mloDetails = []; - foreach (var arch in file.AllArchetypes) + foreach (Archetype? arch in file.AllArchetypes) { if (arch is MloArchetype mlo) { @@ -420,12 +420,12 @@ entry is RpfFileEntry fileEntry return null; List infos = []; - foreach (var stream in file.Streams) + foreach (AwcStream? stream in file.Streams) { if (stream?.StreamInfo == null) continue; - var fmt = stream.FormatChunk; + AwcFormatChunk? fmt = stream.FormatChunk; infos.Add( new Json.AwcStreamInfo { @@ -450,7 +450,7 @@ entry is RpfFileEntry fileEntry int limit = Math.Min(file.TextEntries.Length, 50); for (int i = 0; i < limit; i++) { - var e = file.TextEntries[i]; + Gxt2Entry e = file.TextEntries[i]; string text = e.Text ?? ""; if (text.Length > 100) text = text[..100] + "..."; @@ -480,12 +480,12 @@ private static void AddLod(List lods, string level, DrawableModel[ long totalVerts = 0; long totalTris = 0; - foreach (var model in models) + foreach (DrawableModel model in models) { if (model?.Geometries == null) continue; geomCount += model.Geometries.Length; - foreach (var geom in model.Geometries) + foreach (DrawableGeometry? geom in model.Geometries) { totalVerts += geom.VerticesCount; totalTris += geom.TrianglesCount; @@ -543,7 +543,7 @@ private static void PrintTextResult(Json.InspectResult result, RpfOptions option { case Json.YtdDetails ytd: Console.WriteLine($"Textures: {ytd.TextureCount}"); - foreach (var tex in ytd.Textures) + foreach (Json.TextureInfo tex in ytd.Textures) { Console.WriteLine( $" {tex.Name}: {tex.Width}x{tex.Height} {tex.Format} mips={tex.MipLevels} stride={tex.Stride}" @@ -557,7 +557,7 @@ private static void PrintTextResult(Json.InspectResult result, RpfOptions option case Json.YddDetails ydd: Console.WriteLine($"Drawables: {ydd.DrawableCount}"); - foreach (var d in ydd.Drawables) + foreach (Json.DrawableInfo d in ydd.Drawables) { Console.WriteLine( $" {d.Name}: {d.TotalVertices} vertices, {d.TotalTriangles} triangles" @@ -591,7 +591,7 @@ private static void PrintTextResult(Json.InspectResult result, RpfOptions option ); if (ytyp.MloDetails != null) { - foreach (var mlo in ytyp.MloDetails) + foreach (Json.MloInfo mlo in ytyp.MloDetails) { Console.WriteLine( $" MLO {mlo.Name}: {mlo.EntityCount} entities, {mlo.RoomCount} rooms, {mlo.PortalCount} portals" @@ -608,7 +608,7 @@ private static void PrintTextResult(Json.InspectResult result, RpfOptions option case Json.AwcDetails awc: Console.WriteLine($"Streams: {awc.StreamCount}"); - foreach (var s in awc.Streams) + foreach (Json.AwcStreamInfo s in awc.Streams) { Console.WriteLine( $" Stream {s.Id}: {s.Codec} {s.SamplesPerSecond}Hz {s.Samples} samples" @@ -618,7 +618,7 @@ private static void PrintTextResult(Json.InspectResult result, RpfOptions option case Json.Gxt2Details gxt2: Console.WriteLine($"Text Entries: {gxt2.EntryCount}"); - foreach (var e in gxt2.Entries) + foreach (Json.Gxt2EntryInfo e in gxt2.Entries) { Console.WriteLine($" {e.Hash}: {e.Text}"); } @@ -630,7 +630,7 @@ private static void PrintTextResult(Json.InspectResult result, RpfOptions option private static void PrintLods(IReadOnlyList lods) { - foreach (var lod in lods) + foreach (Json.LodInfo lod in lods) { Console.WriteLine( $" {lod.Level}: {lod.ModelCount} models, {lod.GeometryCount} geometries, {lod.TotalVertices} vertices, {lod.TotalTriangles} triangles" diff --git a/CodeWalker.Cli/Json/DiffResult.cs b/CodeWalker.Cli/Json/DiffResult.cs index 99fff6380..3191da937 100644 --- a/CodeWalker.Cli/Json/DiffResult.cs +++ b/CodeWalker.Cli/Json/DiffResult.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace CodeWalker.Cli.Json; +[ExcludeFromCodeCoverage] internal sealed record DiffResult : BaseResult { [JsonPropertyName("leftRpf")] @@ -27,6 +29,7 @@ internal sealed record DiffResult : BaseResult public required DiffSummary Summary { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record DiffEntry { [JsonPropertyName("path")] @@ -63,6 +66,7 @@ internal sealed record DiffEntry public string? RightSizeFormatted { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record DiffSummary { [JsonPropertyName("addedCount")] diff --git a/CodeWalker.Cli/Json/ExportResult.cs b/CodeWalker.Cli/Json/ExportResult.cs index 741fdea19..ce113560d 100644 --- a/CodeWalker.Cli/Json/ExportResult.cs +++ b/CodeWalker.Cli/Json/ExportResult.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace CodeWalker.Cli.Json; +[ExcludeFromCodeCoverage] internal sealed record ExportFileEntry { [JsonPropertyName("path")] @@ -22,6 +24,7 @@ internal sealed record ExportFileEntry public required string Status { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record ExportResult : BaseResult { [JsonPropertyName("rpfFile")] diff --git a/CodeWalker.Cli/Json/ExtractResult.cs b/CodeWalker.Cli/Json/ExtractResult.cs index ed7c54bde..c86186781 100644 --- a/CodeWalker.Cli/Json/ExtractResult.cs +++ b/CodeWalker.Cli/Json/ExtractResult.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace CodeWalker.Cli.Json; +[ExcludeFromCodeCoverage] internal sealed record ExtractResult : BaseResult { [JsonPropertyName("rpfFile")] diff --git a/CodeWalker.Cli/Json/FileEntry.cs b/CodeWalker.Cli/Json/FileEntry.cs index ad9f3ca38..d9f873800 100644 --- a/CodeWalker.Cli/Json/FileEntry.cs +++ b/CodeWalker.Cli/Json/FileEntry.cs @@ -1,7 +1,9 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace CodeWalker.Cli.Json; +[ExcludeFromCodeCoverage] internal sealed record FileEntry { [JsonPropertyName("path")] diff --git a/CodeWalker.Cli/Json/Gen9Result.cs b/CodeWalker.Cli/Json/Gen9Result.cs index 937acee2d..55e996e30 100644 --- a/CodeWalker.Cli/Json/Gen9Result.cs +++ b/CodeWalker.Cli/Json/Gen9Result.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace CodeWalker.Cli.Json; +[ExcludeFromCodeCoverage] internal sealed record Gen9Result : BaseResult { [JsonPropertyName("inputFolder")] @@ -30,6 +32,7 @@ internal sealed record Gen9Result : BaseResult public required IReadOnlyList Files { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record Gen9FileEntry { [JsonPropertyName("path")] diff --git a/CodeWalker.Cli/Json/HashResult.cs b/CodeWalker.Cli/Json/HashResult.cs index 156caec1e..f6aa018ee 100644 --- a/CodeWalker.Cli/Json/HashResult.cs +++ b/CodeWalker.Cli/Json/HashResult.cs @@ -1,14 +1,17 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace CodeWalker.Cli.Json; +[ExcludeFromCodeCoverage] internal sealed record HashResult : BaseResult { [JsonPropertyName("hashes")] public required IReadOnlyList Hashes { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record HashEntry { [JsonPropertyName("input")] diff --git a/CodeWalker.Cli/Json/InspectResult.cs b/CodeWalker.Cli/Json/InspectResult.cs index 27616e1b7..d5e4a9972 100644 --- a/CodeWalker.Cli/Json/InspectResult.cs +++ b/CodeWalker.Cli/Json/InspectResult.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace CodeWalker.Cli.Json; +[ExcludeFromCodeCoverage] internal sealed record InspectResult : BaseResult { [JsonPropertyName("rpfFile")] @@ -57,6 +59,7 @@ internal sealed record InspectResult : BaseResult public object? Details { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record TextureInfo { [JsonPropertyName("name")] @@ -78,6 +81,7 @@ internal sealed record TextureInfo public required ushort Stride { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record YtdDetails { [JsonPropertyName("textureCount")] @@ -87,6 +91,7 @@ internal sealed record YtdDetails public required IReadOnlyList Textures { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record LodInfo { [JsonPropertyName("level")] @@ -105,12 +110,14 @@ internal sealed record LodInfo public required long TotalTriangles { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record YdrDetails { [JsonPropertyName("lods")] public required IReadOnlyList Lods { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record DrawableInfo { [JsonPropertyName("name")] @@ -123,6 +130,7 @@ internal sealed record DrawableInfo public required long TotalTriangles { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record YddDetails { [JsonPropertyName("drawableCount")] @@ -132,6 +140,7 @@ internal sealed record YddDetails public required IReadOnlyList Drawables { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record YftDetails { [JsonPropertyName("lods")] @@ -141,6 +150,7 @@ internal sealed record YftDetails public required bool HasDrawableCloth { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record YmapDetails { [JsonPropertyName("entityCount")] @@ -169,6 +179,7 @@ internal sealed record YmapDetails public required bool IsScripted { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record YtypDetails { [JsonPropertyName("archetypeCount")] @@ -188,6 +199,7 @@ internal sealed record YtypDetails public IReadOnlyList? MloDetails { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record MloInfo { [JsonPropertyName("name")] @@ -203,6 +215,7 @@ internal sealed record MloInfo public required int PortalCount { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record YbnDetails { [JsonPropertyName("boundsType")] @@ -213,6 +226,7 @@ internal sealed record YbnDetails public int? ChildCount { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record AwcStreamInfo { [JsonPropertyName("id")] @@ -228,6 +242,7 @@ internal sealed record AwcStreamInfo public required uint Samples { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record AwcDetails { [JsonPropertyName("streamCount")] @@ -237,6 +252,7 @@ internal sealed record AwcDetails public required IReadOnlyList Streams { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record Gxt2EntryInfo { [JsonPropertyName("hash")] @@ -246,6 +262,7 @@ internal sealed record Gxt2EntryInfo public required string Text { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record Gxt2Details { [JsonPropertyName("entryCount")] diff --git a/CodeWalker.Cli/Json/ListResult.cs b/CodeWalker.Cli/Json/ListResult.cs index 8c68d717c..d5d8aeece 100644 --- a/CodeWalker.Cli/Json/ListResult.cs +++ b/CodeWalker.Cli/Json/ListResult.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace CodeWalker.Cli.Json; +[ExcludeFromCodeCoverage] internal sealed record ListResult : BaseResult { [JsonPropertyName("rpfFile")] diff --git a/CodeWalker.Cli/Json/PackResult.cs b/CodeWalker.Cli/Json/PackResult.cs index 15cdddc0a..b9e49b283 100644 --- a/CodeWalker.Cli/Json/PackResult.cs +++ b/CodeWalker.Cli/Json/PackResult.cs @@ -1,7 +1,9 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace CodeWalker.Cli.Json; +[ExcludeFromCodeCoverage] internal sealed record PackResult : BaseResult { [JsonPropertyName("inputDir")] diff --git a/CodeWalker.Cli/Json/SearchResult.cs b/CodeWalker.Cli/Json/SearchResult.cs index 69885a90e..252639028 100644 --- a/CodeWalker.Cli/Json/SearchResult.cs +++ b/CodeWalker.Cli/Json/SearchResult.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace CodeWalker.Cli.Json; +[ExcludeFromCodeCoverage] internal sealed record SearchMatch { [JsonPropertyName("path")] @@ -27,6 +29,7 @@ internal sealed record SearchMatch public required uint ShortNameHash { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record SearchResult : BaseResult { [JsonPropertyName("rpfFile")] diff --git a/CodeWalker.Cli/Json/StatResult.cs b/CodeWalker.Cli/Json/StatResult.cs index 7441c0ae5..71a7a5250 100644 --- a/CodeWalker.Cli/Json/StatResult.cs +++ b/CodeWalker.Cli/Json/StatResult.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace CodeWalker.Cli.Json; +[ExcludeFromCodeCoverage] internal sealed record ExtensionStat { [JsonPropertyName("extension")] @@ -27,6 +29,7 @@ internal sealed record ExtensionStat public required long MaxSize { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record StatResult : BaseResult { [JsonPropertyName("rpfFile")] diff --git a/CodeWalker.Cli/Json/TreeResult.cs b/CodeWalker.Cli/Json/TreeResult.cs index 757016002..7b203f3a5 100644 --- a/CodeWalker.Cli/Json/TreeResult.cs +++ b/CodeWalker.Cli/Json/TreeResult.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace CodeWalker.Cli.Json; +[ExcludeFromCodeCoverage] internal sealed record TreeResult : BaseResult { [JsonPropertyName("rpfFile")] @@ -18,6 +20,7 @@ internal sealed record TreeResult : BaseResult public required TreeNode? Root { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record TreeNode { [JsonPropertyName("name")] diff --git a/CodeWalker.Cli/Json/ValidateResult.cs b/CodeWalker.Cli/Json/ValidateResult.cs index 0defb4a5a..3ac644bd2 100644 --- a/CodeWalker.Cli/Json/ValidateResult.cs +++ b/CodeWalker.Cli/Json/ValidateResult.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace CodeWalker.Cli.Json; +[ExcludeFromCodeCoverage] internal sealed record ValidateFileEntry { [JsonPropertyName("path")] @@ -19,6 +21,7 @@ internal sealed record ValidateFileEntry public string? Message { get; init; } } +[ExcludeFromCodeCoverage] internal sealed record ValidateResult : BaseResult { [JsonPropertyName("rpfFile")] diff --git a/CodeWalker.Cli/Polyfills.cs b/CodeWalker.Cli/Polyfills.cs new file mode 100644 index 000000000..e785a3627 --- /dev/null +++ b/CodeWalker.Cli/Polyfills.cs @@ -0,0 +1,141 @@ +#if (!NETCOREAPP2_1_OR_GREATER && !NETSTANDARD2_1_OR_GREATER) || TESTING +using System; +using System.Diagnostics; +using System.Globalization; +using System.Text; +#endif + +namespace CodeWalker.Cli; + +#pragma warning disable IDE0079 // Remove unnecessary suppression + +#if (!NETCOREAPP2_1_OR_GREATER && !NETSTANDARD2_1_OR_GREATER) || TESTING + +internal static class StringExtensions +{ + public static bool Contains(this string s, string value, StringComparison comparisonType) + { +#pragma warning disable CA2249 // Consider using 'string.Contains' instead of 'string.IndexOf'... this is the implementation of Contains! + return s.IndexOf(value, comparisonType) >= 0; +#pragma warning restore CA2249 + } + + public static bool Contains(this string s, char value) + { +#pragma warning disable CA2249 // Consider using 'string.Contains' instead of 'string.IndexOf'... this is the implementation of Contains! + return s.IndexOf(value, StringComparison.Ordinal) >= 0; +#pragma warning restore CA2249 + } + + public static bool Contains(this string s, char value, StringComparison comparisonType) + { +#pragma warning disable CA2249 // Consider using 'string.Contains' instead of 'string.IndexOf'... this is the implementation of Contains! + return s.IndexOf(value, comparisonType) >= 0; +#pragma warning restore CA2249 + } + + public static bool StartsWith(this string s, char value) + { + return s.Length > 0 && s[0] == value; + } + + public static bool StartsWith(this string s, char value, StringComparison comparisonType) + { + return s.StartsWith(value.ToString(), comparisonType); + } + + public static bool EndsWith(this string s, char value) + { + return s.Length > 0 && s[^1] == value; + } + + public static bool EndsWith(this string s, char value, StringComparison comparisonType) + { + return s.EndsWith(value.ToString(), comparisonType); + } + + private static string? ReplaceCore( + ReadOnlySpan searchSpace, + ReadOnlySpan oldValue, + ReadOnlySpan newValue, + CompareInfo compareInfo, + CompareOptions options) + { + Debug.Assert(!oldValue.IsEmpty); + Debug.Assert(compareInfo != null); + + StringBuilder result = new(); + + bool hasDoneAnyReplacements = false; + + while (true) + { + int index = compareInfo.IndexOf(searchSpace, oldValue, options, out int matchLength); + + // There's the possibility that 'oldValue' has zero collation weight (empty string equivalent). + // If this is the case, we behave as if there are no more substitutions to be made. + + if (index < 0 || matchLength == 0) + { + break; + } + + // append the unmodified portion of search space + result.Append(searchSpace[..index]); + + // append the replacement + result.Append(newValue); + + searchSpace = searchSpace[(index + matchLength)..]; + hasDoneAnyReplacements = true; + } + + // Didn't find 'oldValue' in the remaining search space, or the match + // consisted only of zero collation weight characters. As an optimization, + // if we have not yet performed any replacements, we'll save the + // allocation. + + if (!hasDoneAnyReplacements) + { + return null; + } + + // Append what remains of the search space, then allocate the new string. + + result.Append(searchSpace); + return result.ToString(); + } + + public static string Replace(this string s, string oldValue, string? newValue, StringComparison comparisonType) + { + if (comparisonType == StringComparison.Ordinal) + { +#pragma warning disable CA1307 // Specify StringComparison for clarity... this is the implementation of Replace! + return s.Replace(oldValue, newValue); +#pragma warning restore CA1307 + } + + (CompareInfo ci, CompareOptions options) = comparisonType switch + { + StringComparison.CurrentCulture or StringComparison.CurrentCultureIgnoreCase => ( + CultureInfo.CurrentCulture.CompareInfo, + (CompareOptions)((int)comparisonType & (int)CompareOptions.IgnoreCase) + ), + StringComparison.InvariantCulture or StringComparison.InvariantCultureIgnoreCase => ( + CultureInfo.InvariantCulture.CompareInfo, + (CompareOptions)((int)comparisonType & (int)CompareOptions.IgnoreCase) + ), + StringComparison.OrdinalIgnoreCase => ( + CultureInfo.InvariantCulture.CompareInfo, + CompareOptions.OrdinalIgnoreCase + ), + StringComparison.Ordinal => throw new InvalidOperationException("This code path should never be hit, as StringComparison.Ordinal is handled above."), + _ => throw new ArgumentException("The string comparison type passed in is currently not supported.", nameof(comparisonType)), + }; + return ReplaceCore(s, oldValue, newValue, ci, options) ?? s; + } +} + +#endif + +#pragma warning restore IDE0079 // Remove unnecessary suppression diff --git a/CodeWalker.Cli/Program.cs b/CodeWalker.Cli/Program.cs index 4b8525ad2..af0fdd0dc 100644 --- a/CodeWalker.Cli/Program.cs +++ b/CodeWalker.Cli/Program.cs @@ -1,3 +1,4 @@ +#if !TESTING using System.CommandLine; using CodeWalker.Cli; @@ -19,3 +20,4 @@ }; return rootCommand.Parse(args).Invoke(); +#endif diff --git a/CodeWalker.Cli/RpfOptions.cs b/CodeWalker.Cli/RpfOptions.cs index 58dd973df..5b72f4774 100644 --- a/CodeWalker.Cli/RpfOptions.cs +++ b/CodeWalker.Cli/RpfOptions.cs @@ -1,10 +1,12 @@ using System.CommandLine; +using System.Diagnostics.CodeAnalysis; using System.IO; using CodeWalker.Cli.Helpers; namespace CodeWalker.Cli; +[ExcludeFromCodeCoverage] internal sealed record RpfOptions { public required string RpfPath { get; init; } diff --git a/CodeWalker.Cli/SearchHandler.cs b/CodeWalker.Cli/SearchHandler.cs index 4534971d0..163be697b 100644 --- a/CodeWalker.Cli/SearchHandler.cs +++ b/CodeWalker.Cli/SearchHandler.cs @@ -8,10 +8,6 @@ using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; -#if !NETCOREAPP -using CodeWalker.Cli.Polyfills; -#endif - namespace CodeWalker.Cli; internal static class SearchHandler diff --git a/CodeWalker.Cli/StatHandler.cs b/CodeWalker.Cli/StatHandler.cs index e42976d67..870e28534 100644 --- a/CodeWalker.Cli/StatHandler.cs +++ b/CodeWalker.Cli/StatHandler.cs @@ -82,7 +82,7 @@ Json.StatResult ErrorResult(string[] errorMessages) => long compressedSize = 0; long uncompressedSize = 0; - Dictionary extStats = new(); + Dictionary extStats = []; foreach ((RpfFile _, RpfFileEntry fileEntry) in entries) { @@ -92,7 +92,7 @@ Json.StatResult ErrorResult(string[] errorMessages) => if (string.IsNullOrEmpty(ext)) ext = "(none)"; - if (extStats.TryGetValue(ext, out var stat)) + if (extStats.TryGetValue(ext, out (int count, long total, long min, long max) stat)) { extStats[ext] = ( stat.count + 1, diff --git a/CodeWalker.Cli/Tests/CommonOptionsTests.cs b/CodeWalker.Cli/Tests/CommonOptionsTests.cs new file mode 100644 index 000000000..46a51d719 --- /dev/null +++ b/CodeWalker.Cli/Tests/CommonOptionsTests.cs @@ -0,0 +1,91 @@ +using System.CommandLine; + +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +public sealed class CommonOptionsTests +{ + [Fact] + public void Parse_MapsAllValues() + { + RootCommand root = []; + CommonCommandOptions opts = new(); + opts.AddTo(root); + ParseResult pr = root.Parse("--exe /tmp/testdir --verbose --json --si --threads 4"); + Assert.Empty(pr.Errors); + CommonOptions common = opts.Parse(pr); + Assert.Equal("/tmp/testdir", common.ExePath); + Assert.True(common.Verbose); + Assert.True(common.Json); + Assert.Equal(SizeFormat.SI, common.SizeFormat); + Assert.Equal(4, common.Threads); + } + + [Fact] + public void Parse_Defaults() + { + RootCommand root = []; + CommonCommandOptions opts = new(); + opts.AddTo(root); + ParseResult pr = root.Parse("--exe /tmp/testdir"); + Assert.Empty(pr.Errors); + CommonOptions common = opts.Parse(pr); + Assert.False(common.Verbose); + Assert.False(common.Json); + Assert.Equal(SizeFormat.IEC, common.SizeFormat); + Assert.True(common.Threads >= 1); // defaults to Environment.ProcessorCount + } + + [Fact] + public void Parse_SiEnabled_ReturnsSI() + { + RootCommand root = []; + CommonCommandOptions opts = new(); + opts.AddTo(root); + ParseResult pr = root.Parse("--exe /tmp/testdir --si"); + Assert.Equal(SizeFormat.SI, opts.Parse(pr).SizeFormat); + } + + [Fact] + public void ThreadValidator_RejectsZero() + { + RootCommand root = []; + CommonCommandOptions opts = new(); + opts.AddTo(root); + ParseResult pr = root.Parse("--exe /tmp/testdir --threads 0"); + Assert.NotEmpty(pr.Errors); + } + + [Fact] + public void ThreadValidator_AcceptsOne() + { + RootCommand root = []; + CommonCommandOptions opts = new(); + opts.AddTo(root); + ParseResult pr = root.Parse("--exe /tmp/testdir --threads 1"); + Assert.Empty(pr.Errors); + } + + [Fact] + public void AddTo_IncludesThreadsByDefault() + { + RootCommand root = []; + CommonCommandOptions opts = new(); + opts.AddTo(root); + ParseResult pr = root.Parse("--exe /tmp/testdir --threads 2"); + Assert.Empty(pr.Errors); + } + + [Fact] + public void AddTo_ExcludesThreads_WhenFlagIsFalse() + { + RootCommand root = []; + CommonCommandOptions opts = new(); + opts.AddTo(root, includeThreads: false); + ParseResult pr = root.Parse("--exe /tmp/testdir --threads 2"); + Assert.NotEmpty(pr.Errors); + } +} diff --git a/CodeWalker.Cli/Tests/ExportServiceTests.cs b/CodeWalker.Cli/Tests/ExportServiceTests.cs new file mode 100644 index 000000000..35e5f92f3 --- /dev/null +++ b/CodeWalker.Cli/Tests/ExportServiceTests.cs @@ -0,0 +1,408 @@ +using System; +using System.IO; + +using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +[Collection("ConsoleOutput")] +public sealed class ExportServiceExecuteTests +{ + private static ExportOptions MakeOptions(bool json) => + new() + { + Rpf = new RpfOptions + { + RpfPath = "/nonexistent/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, + }, + OutputPath = "/tmp/output", + DryRun = false, + NoOverwrite = false, + Progress = false, + }; + + private static readonly ExportFileProcessor NoOpProcessor = (_, _, _, _) => (null, null); + + [Fact] + public void Execute_ReturnsOne_WhenValidationFails_TextMode() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + int exitCode = ExportService.Execute(MakeOptions(json: false), "xml", "XML", NoOpProcessor); + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_ReturnsOne_WhenValidationFails_JsonMode() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + int exitCode = ExportService.Execute(MakeOptions(json: true), "xml", "XML", NoOpProcessor); + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("RPF file not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_JsonError_ContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + int exitCode = ExportService.Execute(MakeOptions(json: true), "textures", "Textures", NoOpProcessor); + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"format\": \"textures\"", output); + Assert.Contains("\"rpfFile\":", output); + Assert.Contains("\"outputDir\":", output); + } + finally { Console.SetOut(origOut); } + } +} + +public sealed class ProcessSingleFileTests +{ + private static RpfBinaryFileEntry MakeEntry(string path, string name) => + new() { Path = path, Name = name }; + + [Fact] + public void DryRun_ReturnsEntryWithNoError() + { + (Json.ExportFileEntry? entry, string? error) = ExportService.ProcessSingleFile( + MakeEntry("folder/test.ydr", "test.ydr"), + data: null, + outputDir: "/out", + dryRun: true, + noOverwrite: false, + processor: (_, _, _, _) => throw new InvalidOperationException("Should not be called") + ); + + Assert.NotNull(entry); + Assert.Null(error); + Assert.Equal("dry_run", entry.Status); + } + + [Fact] + public void DryRun_EntryHasCorrectFields() + { + (Json.ExportFileEntry? entry, string? _) = ExportService.ProcessSingleFile( + MakeEntry("vehicles/adder.ydr", "adder.ydr"), + data: [1, 2, 3], + outputDir: "/out", + dryRun: true, + noOverwrite: false, + processor: (_, _, _, _) => throw new InvalidOperationException("Should not be called") + ); + + Assert.NotNull(entry); + Assert.Equal("vehicles/adder.ydr", entry.Path); + Assert.Equal("adder.ydr", entry.Name); + Assert.Equal(0, entry.OutputFiles); + Assert.Equal("dry_run", entry.Status); + } + + [Fact] + public void NullData_ReturnsExtractionFailure() + { + (Json.ExportFileEntry? entry, string? error) = ExportService.ProcessSingleFile( + MakeEntry("test.ydr", "test.ydr"), + data: null, + outputDir: "/out", + dryRun: false, + noOverwrite: false, + processor: (_, _, _, _) => throw new InvalidOperationException("Should not be called") + ); + + Assert.Null(entry); + Assert.NotNull(error); + Assert.Contains("Failed to extract", error); + Assert.Contains("test.ydr", error); + } + + [Fact] + public void ProcessorReturnsError_ReturnsFailure() + { + Json.ExportFileEntry errorEntry = new() + { + Path = "test.ydr", + Name = "test.ydr", + OutputFiles = 0, + Status = "error", + }; + + (Json.ExportFileEntry? entry, string? error) = ExportService.ProcessSingleFile( + MakeEntry("test.ydr", "test.ydr"), + data: [1], + outputDir: "/out", + dryRun: false, + noOverwrite: false, + processor: (_, _, _, _) => (errorEntry, "conversion failed") + ); + + Assert.Same(errorEntry, entry); + Assert.Equal("conversion failed", error); + } + + [Fact] + public void ProcessorReturnsSuccessEntry_ReturnsSuccess() + { + Json.ExportFileEntry successEntry = new() + { + Path = "test.ydr", + Name = "test.ydr", + OutputFiles = 3, + Status = "exported", + }; + + (Json.ExportFileEntry? entry, string? error) = ExportService.ProcessSingleFile( + MakeEntry("test.ydr", "test.ydr"), + data: [1], + outputDir: "/out", + dryRun: false, + noOverwrite: false, + processor: (_, _, _, _) => (successEntry, null) + ); + + Assert.Same(successEntry, entry); + Assert.Null(error); + } + + [Fact] + public void ProcessorReturnsNullEntry_ReturnsNoResult() + { + (Json.ExportFileEntry? entry, string? error) = ExportService.ProcessSingleFile( + MakeEntry("test.ydr", "test.ydr"), + data: [1], + outputDir: "/out", + dryRun: false, + noOverwrite: false, + processor: (_, _, _, _) => (null, null) + ); + + Assert.Null(entry); + Assert.NotNull(error); + Assert.Contains("No result for", error); + } + + [Fact] + public void ProcessorReturnsUnsupported_ReturnsSuccess() + { + Json.ExportFileEntry unsupportedEntry = new() + { + Path = "test.ybn", + Name = "test.ybn", + OutputFiles = 0, + Status = "unsupported", + }; + + (Json.ExportFileEntry? entry, string? error) = ExportService.ProcessSingleFile( + MakeEntry("test.ybn", "test.ybn"), + data: [1], + outputDir: "/out", + dryRun: false, + noOverwrite: false, + processor: (_, _, _, _) => (unsupportedEntry, null) + ); + + Assert.Same(unsupportedEntry, entry); + Assert.Null(error); + } + + [Fact] + public void OutputDirectory_ComputedFromBackslashPath() + { + string? capturedOutputDir = null; + + ExportService.ProcessSingleFile( + MakeEntry("x64\\levels\\gta5\\vehicles.rpf\\adder.ydr", "adder.ydr"), + data: [1], + outputDir: "/out", + dryRun: false, + noOverwrite: false, + processor: (_, _, dir, _) => + { + capturedOutputDir = dir; + return ( + new Json.ExportFileEntry + { + Path = "adder.ydr", + Name = "adder.ydr", + OutputFiles = 1, + Status = "exported", + }, + null + ); + } + ); + + Assert.NotNull(capturedOutputDir); + Assert.DoesNotContain("\\", capturedOutputDir); + Assert.StartsWith("/out", capturedOutputDir); + } +} + +public sealed class AggregateResultsTests +{ + private static readonly string[] OneScanError = ["scan error 1"]; + private static readonly string[] OneScanWarning = ["scan warning"]; + + private static Json.ExportFileEntry MakeFileEntry(string status) => + new() + { + Path = $"test_{status}.ydr", + Name = $"test_{status}.ydr", + OutputFiles = 1, + Status = status, + }; + + [Fact] + public void EmptyResults_AllZeros_OnlyScanErrors() + { + ExportService.ExportAggregation agg = ExportService.AggregateResults( + Array.Empty<(Json.ExportFileEntry?, string?)>(), + OneScanError, + filterSkipped: 0 + ); + + Assert.Equal(0, agg.Exported); + Assert.Equal(0, agg.Skipped); + Assert.Equal(0, agg.Errors); + Assert.Empty(agg.Files); + Assert.Single(agg.ErrorMessages); + Assert.Equal("scan error 1", agg.ErrorMessages[0]); + } + + [Fact] + public void CountsExportedAndDryRun_AsExported() + { + (Json.ExportFileEntry?, string?)[] results = + [ + (MakeFileEntry("exported"), null), + (MakeFileEntry("dry_run"), null), + (MakeFileEntry("exported"), null), + ]; + + ExportService.ExportAggregation agg = ExportService.AggregateResults(results, Array.Empty(), filterSkipped: 0); + + Assert.Equal(3, agg.Exported); + } + + [Fact] + public void CountsUnsupportedAndSkipped_AsSkipped() + { + (Json.ExportFileEntry?, string?)[] results = + [ + (MakeFileEntry("unsupported"), null), + (MakeFileEntry("skipped"), null), + ]; + + ExportService.ExportAggregation agg = ExportService.AggregateResults(results, Array.Empty(), filterSkipped: 0); + + Assert.Equal(2, agg.Skipped); + } + + [Fact] + public void AddsFilterSkipped_ToSkippedCount() + { + (Json.ExportFileEntry?, string?)[] results = + [ + (MakeFileEntry("skipped"), null), + ]; + + ExportService.ExportAggregation agg = ExportService.AggregateResults(results, Array.Empty(), filterSkipped: 5); + + Assert.Equal(6, agg.Skipped); + } + + [Fact] + public void CountsErrors_FromFailedResults() + { + (Json.ExportFileEntry?, string?)[] results = + [ + (null, "error 1"), + (null, "error 2"), + (MakeFileEntry("exported"), null), + ]; + + ExportService.ExportAggregation agg = ExportService.AggregateResults(results, Array.Empty(), filterSkipped: 0); + + Assert.Equal(2, agg.Errors); + } + + [Fact] + public void CollectsAllNonNullFileEntries() + { + Json.ExportFileEntry exported = MakeFileEntry("exported"); + Json.ExportFileEntry skipped = MakeFileEntry("skipped"); + + (Json.ExportFileEntry?, string?)[] results = + [ + (exported, null), + (null, "error"), + (skipped, null), + ]; + + ExportService.ExportAggregation agg = ExportService.AggregateResults(results, Array.Empty(), filterSkipped: 0); + + Assert.Equal(2, agg.Files.Count); + Assert.Same(exported, agg.Files[0]); + Assert.Same(skipped, agg.Files[1]); + } + + [Fact] + public void IncludesScanErrorsAndNewErrors_InErrorMessages() + { + (Json.ExportFileEntry?, string?)[] results = + [ + (null, "extraction failed"), + (MakeFileEntry("exported"), null), + ]; + + ExportService.ExportAggregation agg = ExportService.AggregateResults( + results, + OneScanWarning, + filterSkipped: 0 + ); + + Assert.Equal(2, agg.ErrorMessages.Count); + Assert.Equal("scan warning", agg.ErrorMessages[0]); + Assert.Equal("extraction failed", agg.ErrorMessages[1]); + } +} diff --git a/CodeWalker.Cli/Tests/Helpers/FilterTests.cs b/CodeWalker.Cli/Tests/Helpers/FilterTests.cs new file mode 100644 index 000000000..48fbef2d8 --- /dev/null +++ b/CodeWalker.Cli/Tests/Helpers/FilterTests.cs @@ -0,0 +1,91 @@ +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests.Helpers; + +public sealed class FilterTests +{ + [Fact] + public void Normalize_NullOrEmpty_ReturnsEmpty() + { + Assert.Empty(Filter.Normalize(null)); + Assert.Empty(Filter.Normalize([])); + } + + [Fact] + public void Normalize_TrimsAndLowercases() + { + string[] result = Filter.Normalize([" .YDR ", "Foo"]); + Assert.Equal([".ydr", "foo"], result); + } + + [Fact] + public void Normalize_StripsBlankEntries() + { + string[] result = Filter.Normalize(["a", "", " ", "b"]); + Assert.Equal(["a", "b"], result); + } + + [Fact] + public void Matches_NoFilters_MatchesEverything() + { + Assert.True(Filter.Matches("anything.ydr", null)); + Assert.True(Filter.Matches("anything.ydr", [])); + } + + [Fact] + public void Matches_ExtensionWithDot() + { + string[] filters = [".ydr"]; + Assert.True(Filter.Matches("model.ydr", filters)); + Assert.False(Filter.Matches("model.ytd", filters)); + } + + [Fact] + public void Matches_ExtensionWithoutDot() + { + string[] filters = ["ydr"]; + Assert.True(Filter.Matches("model.ydr", filters)); + Assert.False(Filter.Matches("model.ytd", filters)); + } + + [Fact] + public void Matches_WildcardPattern() + { + string[] filters = ["*.ydr"]; + Assert.True(Filter.Matches("model.ydr", filters)); + Assert.True(Filter.Matches("dir/model.ydr", filters)); + Assert.False(Filter.Matches("model.ytd", filters)); + } + + [Fact] + public void Matches_PathPattern() + { + string[] filters = ["vehicles/*.ydr"]; + Assert.True(Filter.Matches("vehicles/car.ydr", filters)); + Assert.False(Filter.Matches("peds/ped.ydr", filters)); + } + + [Fact] + public void Matches_GlobstarPattern() + { + string[] filters = ["**/vehicles/*.ydr"]; + Assert.True(Filter.Matches("x64/dlcpacks/vehicles/car.ydr", filters)); + Assert.True(Filter.Matches("vehicles/car.ydr", filters)); + } + + [Fact] + public void Matches_CaseInsensitive() + { + string[] filters = [".ydr"]; + Assert.True(Filter.Matches("MODEL.YDR", filters)); + } + + [Fact] + public void Matches_BackslashNormalized() + { + string[] filters = ["vehicles\\*.ydr"]; + Assert.True(Filter.Matches("vehicles/car.ydr", filters)); + } +} diff --git a/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs b/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs new file mode 100644 index 000000000..1aa2414cb --- /dev/null +++ b/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs @@ -0,0 +1,285 @@ +using System; +using System.IO; +using System.Reflection; +using System.Threading.Tasks; + +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests.Helpers; + +public sealed class ProgressBarTests +{ + private static int GetCurrent(ProgressBar bar) => + (int)typeof(ProgressBar) + .GetField("_current", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(bar)!; + + private static bool GetEnabled(ProgressBar bar) => + (bool)typeof(ProgressBar) + .GetField("_enabled", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(bar)!; + + private static void ResetThrottle(ProgressBar bar) => + typeof(ProgressBar) + .GetField("_lastUpdate", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(bar, DateTime.MinValue); + + // ── Disabled-state tests ────────────────────────────────────────── + + [Fact] + public void Constructor_disabled_when_enabled_is_false() + { + StringWriter sw = new(); + using ProgressBar bar = new(100, enabled: false, sw); + Assert.False(GetEnabled(bar)); + Assert.Equal("", sw.ToString()); + } + + [Fact] + public void Constructor_disabled_when_total_is_zero() + { + StringWriter sw = new(); + using ProgressBar bar = new(0, enabled: true, sw); + Assert.False(GetEnabled(bar)); + } + + [Fact] + public void Constructor_disabled_when_total_is_negative() + { + StringWriter sw = new(); + using ProgressBar bar = new(-5, enabled: true, sw); + Assert.False(GetEnabled(bar)); + } + + // ── Enabled-state tests ─────────────────────────────────────────── + + [Fact] + public void Constructor_enabled_with_custom_writer() + { + StringWriter sw = new(); + using ProgressBar bar = new(100, enabled: true, sw); + Assert.True(GetEnabled(bar)); + } + + [Fact] + public void Constructor_renders_initial_zero_percent() + { + StringWriter sw = new(); + using ProgressBar bar = new(100, enabled: true, sw, windowWidth: 120); + string output = sw.ToString(); + Assert.StartsWith("[", output); + Assert.Contains("(0/100)", output); + Assert.Contains(">", output); // cursor indicator at start + } + + // ── Render format tests ─────────────────────────────────────────── + + [Fact] + public void Render_at_50_percent_has_half_filled_bar() + { + StringWriter sw = new(); + using ProgressBar bar = new(100, enabled: true, sw, windowWidth: 120); + sw.GetStringBuilder().Clear(); + bar.Update(100 / 2); // 50 == total bypasses throttle? No, 50 < 100. Need to reset throttle. + ResetThrottle(bar); + bar.Update(50); + string output = sw.ToString(); + // 50% => filled = (int)(0.5 * 40) = 20 + Assert.Contains(new string('=', 20) + ">", output); + Assert.Contains("(50/100)", output); + } + + [Fact] + public void Render_at_100_percent_has_full_bar_no_cursor() + { + StringWriter sw = new(); + using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 120); + sw.GetStringBuilder().Clear(); + bar.Update(10); // == total, bypasses throttle + string output = sw.ToString(); + Assert.Contains(new string('=', 40) + "]", output); + Assert.DoesNotContain(">", output); + Assert.Contains("(10/10)", output); + } + + // ── File name tests ─────────────────────────────────────────────── + + [Fact] + public void Render_shows_current_file() + { + StringWriter sw = new(); + using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 120); + sw.GetStringBuilder().Clear(); + bar.Update(10, "textures/player.ytd"); // bypasses throttle at total + string output = sw.ToString(); + Assert.Contains("textures/player.ytd", output); + } + + [Fact] + public void Render_truncates_long_file_with_ellipsis() + { + StringWriter sw = new(); + // windowWidth=80 → maxLen = Max(10, 80-40-30) = 10 + using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 80); + sw.GetStringBuilder().Clear(); + bar.Update(10, "very/long/path/to/some/deeply/nested/file.ytd"); + string output = sw.ToString(); + Assert.Contains("...", output); + Assert.DoesNotContain("very/long/path", output); + } + + [Fact] + public void Render_shows_short_file_without_truncation() + { + StringWriter sw = new(); + using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 200); + sw.GetStringBuilder().Clear(); + bar.Update(10, "short.ytd"); + string output = sw.ToString(); + Assert.Contains("short.ytd", output); + Assert.DoesNotContain("...", output); + } + + // ── State tracking tests ────────────────────────────────────────── + + [Fact] + public void Update_sets_current_value() + { + StringWriter sw = new(); + using ProgressBar bar = new(100, enabled: true, sw); + bar.Update(42); + Assert.Equal(42, GetCurrent(bar)); + } + + [Fact] + public void Increment_advances_by_one() + { + StringWriter sw = new(); + using ProgressBar bar = new(100, enabled: true, sw); + bar.Increment(); + bar.Increment(); + bar.Increment(); + Assert.Equal(3, GetCurrent(bar)); + } + + [Fact] + public void Update_and_Increment_can_interleave() + { + StringWriter sw = new(); + using ProgressBar bar = new(100, enabled: true, sw); + bar.Update(10); + bar.Increment(); + Assert.Equal(11, GetCurrent(bar)); + } + + // ── Throttle tests ──────────────────────────────────────────────── + + [Fact] + public void Throttle_skips_rapid_updates() + { + StringWriter sw = new(); + using ProgressBar bar = new(100, enabled: true, sw, windowWidth: 80); + sw.GetStringBuilder().Clear(); + // Rapid updates — only the first and last should render + for (int i = 1; i <= 50; i++) + bar.Update(i); + string output = sw.ToString(); + // We should see (1/100) from the first un-throttled call + // but NOT every intermediate value + Assert.Contains("(1/100)", output); + Assert.DoesNotContain("(2/100)", output); + } + + [Fact] + public void Throttle_bypassed_at_total() + { + StringWriter sw = new(); + using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 80); + sw.GetStringBuilder().Clear(); + // Update to total always renders even within throttle window + bar.Update(10); + Assert.Contains("(10/10)", sw.ToString()); + } + + [Fact] + public void Throttle_reset_allows_render() + { + StringWriter sw = new(); + using ProgressBar bar = new(100, enabled: true, sw, windowWidth: 80); + sw.GetStringBuilder().Clear(); + ResetThrottle(bar); + bar.Update(25); + Assert.Contains("(25/100)", sw.ToString()); + } + + // ── Dispose tests ───────────────────────────────────────────────── + + [Fact] + public void Dispose_writes_newline_when_enabled() + { + StringWriter sw = new(); + ProgressBar bar = new(10, enabled: true, sw, windowWidth: 80); + bar.Dispose(); + Assert.EndsWith(sw.NewLine, sw.ToString()); + } + + [Fact] + public void Dispose_does_not_throw_when_disabled() + { + ProgressBar bar = new(10, enabled: false, new StringWriter()); + bar.Dispose(); + } + + [Fact] + public void Dispose_can_be_called_multiple_times() + { + ProgressBar bar = new(10, enabled: true, new StringWriter()); + bar.Dispose(); + bar.Dispose(); + } + + // ── Thread safety tests ─────────────────────────────────────────── + + [Fact] + public void Concurrent_increments_are_thread_safe() + { + const int total = 10_000; + StringWriter sw = new(); + using ProgressBar bar = new(total, enabled: true, sw); + + Parallel.For(0, total, _ => bar.Increment()); + + Assert.Equal(total, GetCurrent(bar)); + } + + [Fact] + public void Concurrent_updates_do_not_throw() + { + StringWriter sw = new(); + using ProgressBar bar = new(1000, enabled: true, sw); + + Parallel.For(0, 1000, i => bar.Update(i, $"file_{i}.txt")); + + int current = GetCurrent(bar); + Assert.InRange(current, 0, 999); + } + + // ── Full lifecycle test ─────────────────────────────────────────── + + [Fact] + public void Full_lifecycle_renders_progress_to_completion() + { + StringWriter sw = new(); + using ProgressBar bar = new(5, enabled: true, sw, windowWidth: 120); + for (int i = 0; i < 5; i++) + { + ResetThrottle(bar); + bar.Increment($"step_{i}"); + } + string output = sw.ToString(); + Assert.Contains("(5/5)", output); + Assert.Equal(5, GetCurrent(bar)); + } +} diff --git a/CodeWalker.Cli/Tests/Helpers/SizeFormatTests.cs b/CodeWalker.Cli/Tests/Helpers/SizeFormatTests.cs new file mode 100644 index 000000000..066e1da73 --- /dev/null +++ b/CodeWalker.Cli/Tests/Helpers/SizeFormatTests.cs @@ -0,0 +1,85 @@ +using System; + +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests.Helpers; + +public sealed class SizeFormatTests +{ + [Fact] + public void IEC_ZeroBytes() => + Assert.Equal("0 B", SizeFormat.IEC.ToFormattedString(0)); + + [Fact] + public void IEC_ExactBoundaries() + { + Assert.Equal("1 B", SizeFormat.IEC.ToFormattedString(1)); + Assert.Equal("1 KiB", SizeFormat.IEC.ToFormattedString(1024)); + Assert.Equal("1 MiB", SizeFormat.IEC.ToFormattedString(1024 * 1024)); + Assert.Equal("1 GiB", SizeFormat.IEC.ToFormattedString(1024L * 1024 * 1024)); + Assert.Equal("1 TiB", SizeFormat.IEC.ToFormattedString(1024L * 1024 * 1024 * 1024)); + Assert.Equal("1 PiB", SizeFormat.IEC.ToFormattedString(1024L * 1024 * 1024 * 1024 * 1024)); + Assert.Equal("1024 PiB", SizeFormat.IEC.ToFormattedString(1024L * 1024 * 1024 * 1024 * 1024 * 1024)); + } + + [Fact] + public void IEC_FractionalValues() + { + Assert.Equal("1.5 KiB", SizeFormat.IEC.ToFormattedString(1536)); + Assert.Equal("1.5 MiB", SizeFormat.IEC.ToFormattedString(1536 * 1024)); + Assert.Equal("1.5 GiB", SizeFormat.IEC.ToFormattedString(1536L * 1024 * 1024)); + Assert.Equal("1.5 TiB", SizeFormat.IEC.ToFormattedString(1536L * 1024 * 1024 * 1024)); + Assert.Equal("1.5 PiB", SizeFormat.IEC.ToFormattedString(1536L * 1024 * 1024 * 1024 * 1024)); + Assert.Equal("1536 PiB", SizeFormat.IEC.ToFormattedString(1536L * 1024 * 1024 * 1024 * 1024 * 1024)); + } + + [Fact] + public void SI_ExactBoundaries() + { + Assert.Equal("1 B", SizeFormat.SI.ToFormattedString(1)); + Assert.Equal("1 KB", SizeFormat.SI.ToFormattedString(1000)); + Assert.Equal("1 MB", SizeFormat.SI.ToFormattedString(1_000_000)); + Assert.Equal("1 GB", SizeFormat.SI.ToFormattedString(1_000_000_000)); + Assert.Equal("1 TB", SizeFormat.SI.ToFormattedString(1_000_000_000_000)); + Assert.Equal("1 PB", SizeFormat.SI.ToFormattedString(1_000_000_000_000_000)); + Assert.Equal("1000 PB", SizeFormat.SI.ToFormattedString(1_000_000_000_000_000_000)); + } + + [Fact] + public void SI_FractionalValues() + { + Assert.Equal("1.5 KB", SizeFormat.SI.ToFormattedString(1500)); + Assert.Equal("1.5 MB", SizeFormat.SI.ToFormattedString(1_500_000)); + Assert.Equal("1.5 GB", SizeFormat.SI.ToFormattedString(1_500_000_000)); + Assert.Equal("1.5 TB", SizeFormat.SI.ToFormattedString(1_500_000_000_000)); + Assert.Equal("1.5 PB", SizeFormat.SI.ToFormattedString(1_500_000_000_000_000)); + Assert.Equal("1500 PB", SizeFormat.SI.ToFormattedString(1_500_000_000_000_000_000)); + } + + [Fact] + public void SmallBytes_NoSuffix() + { + Assert.Equal("1023 B", SizeFormat.IEC.ToFormattedString(1023)); + Assert.Equal("999 B", SizeFormat.SI.ToFormattedString(999)); + } + + [Fact] + public void NegativeBytes_FormatsCorrectly() + { + Assert.Equal("-1 B", SizeFormat.IEC.ToFormattedString(-1)); + Assert.Equal("-1 KiB", SizeFormat.IEC.ToFormattedString(-1024)); + Assert.Equal("-1 PiB", SizeFormat.IEC.ToFormattedString(-1024L * 1024 * 1024 * 1024 * 1024)); + Assert.Equal("-1024 PiB", SizeFormat.IEC.ToFormattedString(-1024L * 1024 * 1024 * 1024 * 1024 * 1024)); + + Assert.Equal("-1 B", SizeFormat.SI.ToFormattedString(-1)); + Assert.Equal("-1 KB", SizeFormat.SI.ToFormattedString(-1000)); + Assert.Equal("-1 PB", SizeFormat.SI.ToFormattedString(-1_000_000_000_000_000)); + Assert.Equal("-1000 PB", SizeFormat.SI.ToFormattedString(-1_000_000_000_000_000_000)); + } + + [Fact] + public void InvalidFormat_Throws() => + Assert.Throws(() => ((SizeFormat)999).ToFormattedString(1024)); +} diff --git a/CodeWalker.Cli/Tests/PolyfillsTests.cs b/CodeWalker.Cli/Tests/PolyfillsTests.cs new file mode 100644 index 000000000..106ed6873 --- /dev/null +++ b/CodeWalker.Cli/Tests/PolyfillsTests.cs @@ -0,0 +1,237 @@ +using System; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +/// +/// Fuzz-tests every polyfill in against the built-in +/// .NET implementation. Each method is called with a large combinatorial corpus of +/// hand-picked edge cases plus deterministic random strings, and the result is compared +/// against the equivalent built-in method or string-based overload. +/// +public sealed class StringExtensionsFuzzTests +{ + private static readonly StringComparison[] AllComparisons = + [ + StringComparison.CurrentCulture, + StringComparison.CurrentCultureIgnoreCase, + StringComparison.InvariantCulture, + StringComparison.InvariantCultureIgnoreCase, + StringComparison.Ordinal, + StringComparison.OrdinalIgnoreCase, + ]; + + private static readonly string[] Corpus = BuildCorpus(); + + private static string[] BuildCorpus() + { + string[] handPicked = + [ + "", " ", "a", "A", "ab", "AB", "abc", "ABC", + "hello", "HELLO", "Hello", "Hello World", "hello world", + " spaces ", "tab\there", "new\nline", + "straße", "STRASSE", "Straße", "café", "CAFÉ", + "résumé", "naïve", "日本語", + "abc123!@#", "path/to/file.txt", @"C:\Windows\System32", + "\0null\0", "🎮🎲🎯", new string('x', 200), + "aaa", "aaA", "AaA", + ]; + + // 100 deterministic random strings for breadth + Random rng = new(42); + const string alphabet = "aAbBcC xXyYzZ\t\n\0éß"; + string[] random = new string[100]; + for (int i = 0; i < random.Length; i++) + { + char[] buf = new char[rng.Next(0, 30)]; + for (int j = 0; j < buf.Length; j++) + buf[j] = alphabet[rng.Next(alphabet.Length)]; + random[i] = new string(buf); + } + + string[] result = new string[handPicked.Length + random.Length]; + handPicked.CopyTo(result, 0); + random.CopyTo(result, handPicked.Length); + return result; + } + + private static readonly char[] Chars = + [ + 'a', 'A', 'z', 'Z', ' ', '\t', '\n', '\0', + '/', '\\', '.', '!', 'é', 'ß', 'ñ', '日', 'x', 'X', + ]; + + private static readonly string[] SearchStrings = + [ + "a", "A", "hello", "HELLO", "llo", "World", "world", + "straße", "STRASSE", "ß", "SS", "café", "xyz", " ", + "/", "\\", "\0", "🎮", "xx", + ]; + + // ─── Contains(string, StringComparison) ───────────────────────── + // Polyfill wraps IndexOf; built-in is the native implementation. + + [Fact] + public void Contains_String_Comparison_MatchesBuiltIn() + { + foreach (string s in Corpus) + foreach (string sub in SearchStrings) + foreach (StringComparison cmp in AllComparisons) + AssertBool( + s.Contains(sub, cmp), + StringExtensions.Contains(s, sub, cmp), + $"Contains(\"{Esc(s)}\", \"{Esc(sub)}\", {cmp})"); + } + + // ─── Contains(char) ───────────────────────────────────────────── + // Built-in string.Contains(char) exists on .NET 5+. + + [Fact] + public void Contains_Char_MatchesBuiltIn() + { + foreach (string s in Corpus) + foreach (char c in Chars) + AssertBool( + s.Contains(c), + StringExtensions.Contains(s, c), + $"Contains(\"{Esc(s)}\", '{c}')"); + } + + // ─── Contains(char, StringComparison) ─────────────────────────── + // No built-in char overload; verify against string-based Contains. + + [Fact] + public void Contains_Char_Comparison_MatchesStringOverload() + { + foreach (string s in Corpus) + foreach (char c in Chars) + foreach (StringComparison cmp in AllComparisons) + AssertBool( + s.Contains(c.ToString(), cmp), + StringExtensions.Contains(s, c, cmp), + $"Contains(\"{Esc(s)}\", '{c}', {cmp})"); + } + + // ─── StartsWith(char) ─────────────────────────────────────────── + // Verify against string-based StartsWith with Ordinal comparison. + + [Fact] + public void StartsWith_Char_MatchesStringOverload() + { + foreach (string s in Corpus) + foreach (char c in Chars) + AssertBool( + s.StartsWith(c.ToString(), StringComparison.Ordinal), + StringExtensions.StartsWith(s, c), + $"StartsWith(\"{Esc(s)}\", '{c}')"); + } + + // ─── StartsWith(char, StringComparison) ───────────────────────── + + [Fact] + public void StartsWith_Char_Comparison_MatchesStringOverload() + { + foreach (string s in Corpus) + foreach (char c in Chars) + foreach (StringComparison cmp in AllComparisons) + AssertBool( + s.StartsWith(c.ToString(), cmp), + StringExtensions.StartsWith(s, c, cmp), + $"StartsWith(\"{Esc(s)}\", '{c}', {cmp})"); + } + + // ─── EndsWith(char) ───────────────────────────────────────────── + // Verify against string-based EndsWith with Ordinal comparison. + + [Fact] + public void EndsWith_Char_MatchesStringOverload() + { + foreach (string s in Corpus) + foreach (char c in Chars) + AssertBool( + s.EndsWith(c.ToString(), StringComparison.Ordinal), + StringExtensions.EndsWith(s, c), + $"EndsWith(\"{Esc(s)}\", '{c}')"); + } + + // ─── EndsWith(char, StringComparison) ─────────────────────────── + + [Fact] + public void EndsWith_Char_Comparison_MatchesStringOverload() + { + foreach (string s in Corpus) + foreach (char c in Chars) + foreach (StringComparison cmp in AllComparisons) + AssertBool( + s.EndsWith(c.ToString(), cmp), + StringExtensions.EndsWith(s, c, cmp), + $"EndsWith(\"{Esc(s)}\", '{c}', {cmp})"); + } + + // ─── Replace(string, string?, StringComparison) ───────────────── + // Ordinal path delegates to built-in; non-Ordinal uses ReplaceCore. + + [Fact] + public void Replace_MatchesBuiltIn() + { + string[] oldValues = ["a", "A", "hello", "HELLO", "llo", "straße", "SS", "ß", " ", "xx"]; + string?[] newValues = [null, "", "X", "YY", "replaced"]; + + foreach (string s in Corpus) + foreach (string old in oldValues) + foreach (string? @new in newValues) + foreach (StringComparison cmp in AllComparisons) + AssertString( + s.Replace(old, @new, cmp), + StringExtensions.Replace(s, old, @new, cmp), + $"Replace(\"{Esc(s)}\", \"{Esc(old)}\", \"{Esc(@new)}\", {cmp})"); + } + + // ─── Helpers ──────────────────────────────────────────────────── + + private static void AssertBool(bool expected, bool actual, string label) + { + Assert.True(expected == actual, $"{label}: expected={expected} actual={actual}"); + } + + private static void AssertString(string expected, string actual, string label) + { + Assert.True(string.Equals(expected, actual, StringComparison.Ordinal), + $"{label}: expected=\"{Esc(expected)}\" actual=\"{Esc(actual)}\""); + } + + private static string Esc(string? s) => + s?.Replace("\0", "\\0", StringComparison.Ordinal) + .Replace("\n", "\\n", StringComparison.Ordinal) + .Replace("\r", "\\r", StringComparison.Ordinal) + .Replace("\t", "\\t", StringComparison.Ordinal) ?? "(null)"; +} + +public sealed class StringExtensionsUnitTests +{ + [Fact] + public void Replace_NullOldValue_Throws() + { + Assert.Throws(() => StringExtensions.Replace("input", null!, "new", StringComparison.Ordinal)); + } + + [Fact] + public void Replace_EmptyOldValue_Throws() + { + Assert.Throws(() => StringExtensions.Replace("input", "", "new", StringComparison.Ordinal)); + } + + [Fact] + public void Replace_NullNewValue_DoesNotThrow() + { + string result = StringExtensions.Replace("input", "in", null, StringComparison.Ordinal); + Assert.Equal("input".Replace("in", null), result); + } + + [Fact] + public void Replace_UnsupportedComparison_Throws() + { + Assert.Throws(() => StringExtensions.Replace("input", "in", "new", (StringComparison)999)); + } +} diff --git a/CodeWalker.Cli/Tests/RpfOptionsTests.cs b/CodeWalker.Cli/Tests/RpfOptionsTests.cs new file mode 100644 index 000000000..f00bd4357 --- /dev/null +++ b/CodeWalker.Cli/Tests/RpfOptionsTests.cs @@ -0,0 +1,75 @@ +using System.CommandLine; + +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +public sealed class RpfOptionsTests +{ + [Fact] + public void Parse_MapsAllValues() + { + RootCommand root = []; + RpfCommandOptions opts = new(); + opts.AddTo(root); + ParseResult pr = root.Parse( + "--rpf /tmp/test.rpf --exe /tmp/testdir --gen9 --recursive --verbose --json --si --threads 4 --filter *.ydr" + ); + Assert.Empty(pr.Errors); + RpfOptions rpfOpts = opts.Parse(pr); + Assert.Equal("/tmp/test.rpf", rpfOpts.RpfPath); + Assert.Equal("/tmp/testdir", rpfOpts.ExePath); + Assert.True(rpfOpts.Gen9); + Assert.True(rpfOpts.Recursive); + Assert.True(rpfOpts.Verbose); + Assert.True(rpfOpts.Json); + Assert.Equal(SizeFormat.SI, rpfOpts.SizeFormat); + Assert.Equal(4, rpfOpts.Threads); + } + + [Fact] + public void Parse_Defaults() + { + RootCommand root = []; + RpfCommandOptions opts = new(); + opts.AddTo(root); + ParseResult pr = root.Parse("--rpf /tmp/test.rpf --exe /tmp/testdir"); + Assert.Empty(pr.Errors); + RpfOptions rpfOpts = opts.Parse(pr); + Assert.False(rpfOpts.Gen9); + Assert.False(rpfOpts.Recursive); + Assert.False(rpfOpts.Verbose); + Assert.False(rpfOpts.Json); + Assert.Equal(SizeFormat.IEC, rpfOpts.SizeFormat); + Assert.Empty(rpfOpts.Filters); + } + + [Fact] + public void Parse_MultipleFilters() + { + RootCommand root = []; + RpfCommandOptions opts = new(); + opts.AddTo(root); + ParseResult pr = root.Parse("--rpf /tmp/test.rpf --exe /tmp/testdir --filter *.ydr *.ytd"); + Assert.Empty(pr.Errors); + RpfOptions rpfOpts = opts.Parse(pr); + Assert.Equal(2, rpfOpts.Filters.Length); + } + + [Fact] + public void Parse_Aliases() + { + RootCommand root = []; + RpfCommandOptions opts = new(); + opts.AddTo(root); + ParseResult pr = root.Parse("-r /tmp/test.rpf -e /tmp/testdir -g -R -v -t 2"); + Assert.Empty(pr.Errors); + RpfOptions rpfOpts = opts.Parse(pr); + Assert.True(rpfOpts.Gen9); + Assert.True(rpfOpts.Recursive); + Assert.True(rpfOpts.Verbose); + Assert.Equal(2, rpfOpts.Threads); + } +} diff --git a/CodeWalker.Cli/Tests/RpfServiceTests.cs b/CodeWalker.Cli/Tests/RpfServiceTests.cs new file mode 100644 index 000000000..5e127ec56 --- /dev/null +++ b/CodeWalker.Cli/Tests/RpfServiceTests.cs @@ -0,0 +1,431 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using CodeWalker.GameFiles; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +[Collection("ConsoleOutput")] +public sealed class RpfServiceTests +{ + private static string CreateTempDir() + { + string dir = Path.Combine(Path.GetTempPath(), "cw_test_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + return dir; + } + + // --- ValidateExe --- + + [Fact] + public void ValidateExe_ReturnsNull_WhenExeExists() + { + string dir = CreateTempDir(); + try + { + File.WriteAllBytes(Path.Combine(dir, "GTA5.exe"), []); + Assert.Null(RpfService.ValidateExe(dir, gen9: false)); + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void ValidateExe_ReturnsError_WhenExeMissing() + { + string dir = CreateTempDir(); + try + { + string? error = RpfService.ValidateExe(dir, gen9: false); + Assert.NotNull(error); + Assert.Contains("GTA5.exe", error); + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void ValidateExe_Gen9_ReturnsNull_WhenEnhancedExeExists() + { + string dir = CreateTempDir(); + try + { + File.WriteAllBytes(Path.Combine(dir, "GTA5_Enhanced.exe"), []); + Assert.Null(RpfService.ValidateExe(dir, gen9: true)); + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void ValidateExe_Gen9_ReturnsError_WhenEnhancedExeMissing() + { + string dir = CreateTempDir(); + try + { + string? error = RpfService.ValidateExe(dir, gen9: true); + Assert.NotNull(error); + Assert.Contains("GTA5_Enhanced.exe", error); + } + finally { Directory.Delete(dir, true); } + } + + // --- ValidateInputs --- + + [Fact] + public void ValidateInputs_ReturnsNull_WhenBothExist() + { + string dir = CreateTempDir(); + try + { + string rpf = Path.Combine(dir, "test.rpf"); + File.WriteAllBytes(rpf, []); + File.WriteAllBytes(Path.Combine(dir, "GTA5.exe"), []); + Assert.Null(RpfService.ValidateInputs(rpf, dir, gen9: false)); + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void ValidateInputs_ReturnsError_WhenRpfMissing() + { + string? error = RpfService.ValidateInputs("/nonexistent/test.rpf", "/tmp", gen9: false); + Assert.NotNull(error); + Assert.Contains("RPF file not found", error); + } + + [Fact] + public void ValidateInputs_ReturnsError_WhenExeMissing() + { + string dir = CreateTempDir(); + try + { + string rpf = Path.Combine(dir, "test.rpf"); + File.WriteAllBytes(rpf, []); + string? error = RpfService.ValidateInputs(rpf, dir, gen9: false); + Assert.NotNull(error); + Assert.Contains("GTA5.exe", error); + } + finally { Directory.Delete(dir, true); } + } + + // --- ValidateExeAndLoadKeys / ValidateAndLoadKeys early-return --- + + [Fact] + public void ValidateExeAndLoadKeys_ReturnsError_WhenExeMissing() + { + string dir = CreateTempDir(); + try + { + string? error = RpfService.ValidateExeAndLoadKeys(dir, gen9: false, json: true); + Assert.NotNull(error); + Assert.Contains("GTA5.exe", error); + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void ValidateAndLoadKeys_ReturnsError_WhenRpfMissing() + { + string? error = RpfService.ValidateAndLoadKeys( + "/nonexistent.rpf", "/tmp", gen9: false, json: true + ); + Assert.NotNull(error); + Assert.Contains("RPF file not found", error); + } + + // --- GetFileType --- + + [Fact] + public void GetFileType_Resource() => + Assert.Equal("resource", RpfService.GetFileType(new RpfResourceFileEntry())); + + [Fact] + public void GetFileType_Binary() => + Assert.Equal("binary", RpfService.GetFileType(new RpfBinaryFileEntry())); + + // --- CollectFiles --- + + private static RpfBinaryFileEntry MakeEntry(string name, string? path = null) => + new() { Name = name, NameLower = name.ToLowerInvariant(), Path = path ?? name }; + + [Fact] + public void CollectFiles_NullEntries_ReturnsEmpty() + { + RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = null }; + Assert.Empty(RpfService.CollectFiles(rpf, null, recursive: false)); + } + + [Fact] + public void CollectFiles_ReturnsFileEntries() + { + RpfBinaryFileEntry entry = MakeEntry("test.ydr"); + RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = [entry] }; + List<(RpfFile rpf, RpfFileEntry entry)> files = + RpfService.CollectFiles(rpf, null, recursive: false); + Assert.Single(files); + Assert.Same(entry, files[0].entry); + } + + [Fact] + public void CollectFiles_SkipsRpfEntries() + { + RpfBinaryFileEntry rpfEntry = MakeEntry("nested.rpf"); + RpfBinaryFileEntry fileEntry = MakeEntry("test.ydr"); + RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = [rpfEntry, fileEntry] }; + List<(RpfFile rpf, RpfFileEntry entry)> files = + RpfService.CollectFiles(rpf, null, recursive: false); + Assert.Single(files); + Assert.Equal("test.ydr", files[0].entry.Name); + } + + [Fact] + public void CollectFiles_SkipsDirectoryEntries() + { + RpfDirectoryEntry dirEntry = new() { Name = "subdir", NameLower = "subdir", Path = "subdir" }; + RpfBinaryFileEntry fileEntry = MakeEntry("test.ydr"); + RpfFile rpf = new("test", "test.rpf", 0) + { + AllEntries = [dirEntry, fileEntry], + }; + List<(RpfFile rpf, RpfFileEntry entry)> files = + RpfService.CollectFiles(rpf, null, recursive: false); + Assert.Single(files); + } + + [Fact] + public void CollectFiles_AppliesFilter() + { + RpfBinaryFileEntry e1 = MakeEntry("test.ydr"); + RpfBinaryFileEntry e2 = MakeEntry("test.ytd"); + RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = [e1, e2] }; + List<(RpfFile rpf, RpfFileEntry entry)> files = + RpfService.CollectFiles(rpf, ["*.ydr"], recursive: false); + Assert.Single(files); + Assert.Equal("test.ydr", files[0].entry.Name); + } + + [Fact] + public void CollectFiles_Recursive_IncludesChildren() + { + RpfBinaryFileEntry parentEntry = MakeEntry("a.ydr"); + RpfBinaryFileEntry childEntry = MakeEntry("b.ydr"); + RpfFile child = new("child", "child.rpf", 0) { AllEntries = [childEntry] }; + RpfFile parent = new("parent", "parent.rpf", 0) + { + AllEntries = [parentEntry], + Children = [child], + }; + Assert.Equal(2, RpfService.CollectFiles(parent, null, recursive: true).Count); + } + + [Fact] + public void CollectFiles_NonRecursive_ExcludesChildren() + { + RpfBinaryFileEntry parentEntry = MakeEntry("a.ydr"); + RpfBinaryFileEntry childEntry = MakeEntry("b.ydr"); + RpfFile child = new("child", "child.rpf", 0) { AllEntries = [childEntry] }; + RpfFile parent = new("parent", "parent.rpf", 0) + { + AllEntries = [parentEntry], + Children = [child], + }; + List<(RpfFile rpf, RpfFileEntry entry)> files = + RpfService.CollectFiles(parent, null, recursive: false); + Assert.Single(files); + Assert.Equal("a.ydr", files[0].entry.Name); + } + + [Fact] + public void CollectFiles_Recursive_ReturnsCorrectRpfRef() + { + RpfBinaryFileEntry childEntry = MakeEntry("b.ydr"); + RpfFile child = new("child", "child.rpf", 0) { AllEntries = [childEntry] }; + RpfFile parent = new("parent", "parent.rpf", 0) + { + AllEntries = [], + Children = [child], + }; + List<(RpfFile rpf, RpfFileEntry entry)> files = + RpfService.CollectFiles(parent, null, recursive: true); + Assert.Single(files); + Assert.Same(child, files[0].rpf); + } + + // --- CountNonRpfFiles --- + + [Fact] + public void CountNonRpfFiles_CountsCorrectly() + { + RpfFile rpf = new("test", "test.rpf", 0) + { + AllEntries = [MakeEntry("a.ydr"), MakeEntry("b.ytd")], + }; + Assert.Equal(2, RpfService.CountNonRpfFiles(rpf, recursive: false)); + } + + [Fact] + public void CountNonRpfFiles_SkipsRpfFiles() + { + RpfFile rpf = new("test", "test.rpf", 0) + { + AllEntries = [MakeEntry("nested.rpf"), MakeEntry("test.ydr")], + }; + Assert.Equal(1, RpfService.CountNonRpfFiles(rpf, recursive: false)); + } + + [Fact] + public void CountNonRpfFiles_Recursive() + { + RpfFile child = new("child", "child.rpf", 0) { AllEntries = [MakeEntry("b.ydr")] }; + RpfFile parent = new("parent", "parent.rpf", 0) + { + AllEntries = [MakeEntry("a.ydr")], + Children = [child], + }; + Assert.Equal(2, RpfService.CountNonRpfFiles(parent, recursive: true)); + } + + [Fact] + public void CountNonRpfFiles_NonRecursive_ExcludesChildren() + { + RpfFile child = new("child", "child.rpf", 0) { AllEntries = [MakeEntry("b.ydr")] }; + RpfFile parent = new("parent", "parent.rpf", 0) + { + AllEntries = [MakeEntry("a.ydr")], + Children = [child], + }; + Assert.Equal(1, RpfService.CountNonRpfFiles(parent, recursive: false)); + } + + [Fact] + public void CountNonRpfFiles_NullEntries_ReturnsZero() + { + RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = null }; + Assert.Equal(0, RpfService.CountNonRpfFiles(rpf, recursive: false)); + } + + // --- ReportError --- + + private static Json.ExportResult MakeBaseResult(string[]? errors = null) => + new() + { + Success = true, + RpfFile = "test.rpf", + OutputDir = "/tmp", + Format = "xml", + TotalFiles = 0, + Exported = 0, + Skipped = 0, + Errors = 0, + DryRun = false, + Files = [], + ErrorMessages = errors ?? [], + }; + + [Fact] + public void ReportError_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + Console.SetError(new StringWriter()); + Assert.Equal(1, RpfService.ReportError("err", json: false, MakeBaseResult())); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void ReportError_Json_WritesToStdout() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + Console.SetError(new StringWriter()); + RpfService.ReportError("test error", json: true, MakeBaseResult()); + string output = sw.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("test error", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void ReportError_Json_PreservesExistingErrors() + { + TextWriter origOut = Console.Out; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + RpfService.ReportError("new error", json: true, MakeBaseResult(["old error"])); + string output = sw.ToString(); + Assert.Contains("old error", output); + Assert.Contains("new error", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void ReportError_Text_WritesToStderr() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + RpfService.ReportError("test error", json: false, MakeBaseResult()); + Assert.Contains("Error: test error", stderr.ToString()); + Assert.Equal("", stdout.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void ReportError_Text_IncludesStackTrace() + { + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetError(stderr); + RpfService.ReportError("err", json: false, MakeBaseResult(), "at Foo.Bar()"); + Assert.Contains("at Foo.Bar()", stderr.ToString()); + } + finally { Console.SetError(origErr); } + } + + [Fact] + public void ReportError_Text_OmitsStackTrace_WhenNull() + { + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetError(stderr); + RpfService.ReportError("err", json: false, MakeBaseResult()); + Assert.DoesNotContain("at ", stderr.ToString()); + } + finally { Console.SetError(origErr); } + } +} diff --git a/CodeWalker.sln b/CodeWalker.sln index c714993e8..6cad1895c 100644 --- a/CodeWalker.sln +++ b/CodeWalker.sln @@ -29,6 +29,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeWalker.Gen9Converter", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodeWalker.Cli", "CodeWalker.Cli\CodeWalker.Cli.csproj", "{D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeWalker.Cli.Tests", "CodeWalker.Cli\CodeWalker.Cli.Tests.csproj", "{414B22C4-53F2-4F7D-841B-B1AA8461F213}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -170,10 +172,24 @@ Global {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Release|x64.Build.0 = Release|Any CPU {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Release|x86.ActiveCfg = Release|Any CPU {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Release|x86.Build.0 = Release|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Debug|Any CPU.Build.0 = Debug|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Debug|x64.ActiveCfg = Debug|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Debug|x64.Build.0 = Debug|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Debug|x86.ActiveCfg = Debug|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Debug|x86.Build.0 = Debug|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Release|Any CPU.ActiveCfg = Release|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Release|Any CPU.Build.0 = Release|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Release|x64.ActiveCfg = Release|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Release|x64.Build.0 = Release|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Release|x86.ActiveCfg = Release|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5D6153C2-98D1-4C3D-9232-4C2BEEAEC8E0} EndGlobalSection From cd3b027d729f058b05e1e21e74c8927c09a1c0a3 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:26:32 +0200 Subject: [PATCH 12/45] style(cli): tighten the editorconfig and reformat to match No behaviour change. this. on instance members, predefined type names, parentheses in binary expressions and discards for unused results were all at silent or suggestion, so nothing enforced them and the tree drifted. They are suggestions or warnings now, IDE0130 included, and the reformat is what that produced. --- CodeWalker.Cli/.editorconfig | 96 ++++++++++++------------- CodeWalker.Cli/CommonOptions.cs | 31 ++++---- CodeWalker.Cli/DiffHandler.cs | 2 +- CodeWalker.Cli/ExportAudioHandler.cs | 4 +- CodeWalker.Cli/ExportHandler.cs | 27 ++++--- CodeWalker.Cli/ExportService.cs | 4 +- CodeWalker.Cli/ExportTextHandler.cs | 2 +- CodeWalker.Cli/ExportTexturesHandler.cs | 2 +- CodeWalker.Cli/ExportXmlHandler.cs | 2 +- CodeWalker.Cli/ExtractHandler.cs | 8 +-- CodeWalker.Cli/Gen9Handler.cs | 12 ++-- CodeWalker.Cli/Helpers/Filter.cs | 7 +- CodeWalker.Cli/Helpers/ProgressBar.cs | 78 ++++++++++---------- CodeWalker.Cli/InspectHandler.cs | 18 ++--- CodeWalker.Cli/PackHandler.cs | 4 +- CodeWalker.Cli/Polyfills.cs | 6 +- CodeWalker.Cli/RpfOptions.cs | 20 +++--- CodeWalker.Cli/RpfService.cs | 19 +++-- CodeWalker.Cli/SearchHandler.cs | 9 ++- CodeWalker.Cli/ValidateHandler.cs | 7 +- 20 files changed, 173 insertions(+), 185 deletions(-) diff --git a/CodeWalker.Cli/.editorconfig b/CodeWalker.Cli/.editorconfig index 77477c8bc..41be91a32 100644 --- a/CodeWalker.Cli/.editorconfig +++ b/CodeWalker.Cli/.editorconfig @@ -30,20 +30,20 @@ dotnet_sort_system_directives_first = true file_header_template = unset # this. and Me. preferences -dotnet_style_qualification_for_event = false:silent -dotnet_style_qualification_for_field = false:silent -dotnet_style_qualification_for_method = false:silent -dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_field = true:suggestion +dotnet_style_qualification_for_property = true:suggestion +dotnet_style_qualification_for_method = true:suggestion +dotnet_style_qualification_for_event = true:suggestion # Language keywords vs BCL types preferences -dotnet_style_predefined_type_for_locals_parameters_members = true:silent -dotnet_style_predefined_type_for_member_access = true:silent +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion # Parentheses preferences -dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:suggestion dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent -dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent # Modifier preferences dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning @@ -58,7 +58,7 @@ dotnet_style_object_initializer = true:warning dotnet_style_operator_placement_when_wrapping = beginning_of_line dotnet_style_prefer_auto_properties = true:suggestion dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion -dotnet_style_prefer_compound_assignment = true:warning +dotnet_style_prefer_compound_assignment = true:suggestion dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion dotnet_style_prefer_conditional_expression_over_return = true:suggestion dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion @@ -86,105 +86,105 @@ csharp_style_var_for_built_in_types = false:suggestion csharp_style_var_when_type_is_apparent = false:suggestion # Expression-bodied members -csharp_style_expression_bodied_accessors = true:silent -csharp_style_expression_bodied_constructors = false:suggestion -csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:suggestion +csharp_style_expression_bodied_constructors = true:suggestion +csharp_style_expression_bodied_indexers = true:suggestion csharp_style_expression_bodied_lambdas = true:suggestion -csharp_style_expression_bodied_local_functions = false:silent -csharp_style_expression_bodied_methods = false:silent -csharp_style_expression_bodied_operators = false:silent -csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_local_functions = true:silent +csharp_style_expression_bodied_methods = true:suggestion +csharp_style_expression_bodied_operators = true:silent +csharp_style_expression_bodied_properties = true:suggestion # Pattern matching preferences csharp_style_pattern_matching_over_as_with_null_check = true:warning csharp_style_pattern_matching_over_is_with_cast_check = true:warning csharp_style_prefer_extended_property_pattern = true:suggestion csharp_style_prefer_not_pattern = true:suggestion -csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_pattern_matching = true:suggestion csharp_style_prefer_switch_expression = true:warning # Null-checking preferences csharp_style_conditional_delegate_call = true:suggestion # Modifier preferences -csharp_prefer_static_anonymous_function = true:suggestion +csharp_prefer_static_anonymous_function = true:warning csharp_prefer_static_local_function = true:warning csharp_preferred_modifier_order = public,private,protected,internal,file,const,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion csharp_style_prefer_readonly_struct = true:suggestion csharp_style_prefer_readonly_struct_member = true:suggestion # Code-block preferences -csharp_prefer_braces = true:suggestion +csharp_prefer_braces = when_multiline:suggestion csharp_prefer_simple_using_statement = true:suggestion csharp_style_namespace_declarations = file_scoped:warning csharp_style_prefer_method_group_conversion = true:suggestion csharp_style_prefer_primary_constructors = true:suggestion -csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_top_level_statements = true:suggestion # Expression-level preferences csharp_prefer_simple_default_expression = true:suggestion csharp_style_deconstructed_variable_declaration = true:suggestion csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion csharp_style_inlined_variable_declaration = true:warning -csharp_style_prefer_index_operator = true:warning +csharp_style_prefer_index_operator = true:suggestion csharp_style_prefer_local_over_anonymous_function = true:suggestion csharp_style_prefer_null_check_over_type_check = true:warning -csharp_style_prefer_range_operator = true:warning +csharp_style_prefer_range_operator = true:suggestion csharp_style_prefer_tuple_swap = true:suggestion csharp_style_prefer_utf8_string_literals = true:suggestion csharp_style_throw_expression = true:suggestion -csharp_style_unused_value_assignment_preference = discard_variable:suggestion -csharp_style_unused_value_expression_statement_preference = discard_variable:silent +csharp_style_unused_value_assignment_preference = discard_variable:warning +csharp_style_unused_value_expression_statement_preference = discard_variable:warning # 'using' directive preferences -csharp_using_directive_placement = outside_namespace:silent +csharp_using_directive_placement = outside_namespace:suggestion #### C# Formatting Rules #### -# New line preferences -csharp_new_line_before_catch = true +# New-line preferences +csharp_new_line_before_open_brace = all csharp_new_line_before_else = true +csharp_new_line_before_catch = true csharp_new_line_before_finally = true -csharp_new_line_before_members_in_anonymous_types = true csharp_new_line_before_members_in_object_initializers = true -csharp_new_line_before_open_brace = all +csharp_new_line_before_members_in_anonymous_types = true csharp_new_line_between_query_expression_clauses = true # Indentation preferences +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current csharp_indent_block_contents = true csharp_indent_braces = false -csharp_indent_case_contents = true csharp_indent_case_contents_when_block = true -csharp_indent_labels = one_less_than_current -csharp_indent_switch_labels = true # Space preferences csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false csharp_space_after_comma = true +csharp_space_before_comma = false csharp_space_after_dot = false -csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_before_dot = false csharp_space_after_semicolon_in_for_statement = true -csharp_space_around_binary_operators = before_and_after +csharp_space_before_semicolon_in_for_statement = false csharp_space_around_declaration_statements = false -csharp_space_before_colon_in_inheritance_clause = true -csharp_space_before_comma = false -csharp_space_before_dot = false csharp_space_before_open_square_brackets = false -csharp_space_before_semicolon_in_for_statement = false csharp_space_between_empty_square_brackets = false -csharp_space_between_method_call_empty_parameter_list_parentheses = false -csharp_space_between_method_call_name_and_opening_parenthesis = false -csharp_space_between_method_call_parameter_list_parentheses = false -csharp_space_between_method_declaration_empty_parameter_list_parentheses = false -csharp_space_between_method_declaration_name_and_open_parenthesis = false -csharp_space_between_method_declaration_parameter_list_parentheses = false -csharp_space_between_parentheses = false csharp_space_between_square_brackets = false # Wrapping preferences -csharp_preserve_single_line_blocks = true csharp_preserve_single_line_statements = true +csharp_preserve_single_line_blocks = true #### .NET Code Quality Rules (CA) #### [*.cs] @@ -210,7 +210,7 @@ dotnet_diagnostic.IDE0005.severity = warning # Remove unnecessary using dir dotnet_diagnostic.IDE0051.severity = warning # Remove unused private members dotnet_diagnostic.IDE0052.severity = warning # Remove unread private members dotnet_diagnostic.IDE0060.severity = warning # Remove unused parameter -dotnet_diagnostic.IDE0130.severity = suggestion # Namespace does not match folder structure +dotnet_diagnostic.IDE0130.severity = warning # Namespace does not match folder structure dotnet_diagnostic.IDE0290.severity = suggestion # Use primary constructors #### Roslynator Rules (RCS) #### diff --git a/CodeWalker.Cli/CommonOptions.cs b/CodeWalker.Cli/CommonOptions.cs index 071cf6641..ec4ddbab2 100644 --- a/CodeWalker.Cli/CommonOptions.cs +++ b/CodeWalker.Cli/CommonOptions.cs @@ -53,32 +53,29 @@ internal sealed class CommonCommandOptions public CommonCommandOptions() { - Threads.Validators.Add(result => + this.Threads.Validators.Add(result => { - if (result.GetValue(Threads) < 1) + if (result.GetValue(this.Threads) < 1) result.AddError("--threads must be at least 1."); }); } public void AddTo(Command command, bool includeThreads = true) { - command.Add(Exe); - command.Add(Verbose); - command.Add(Json); - command.Add(Si); + command.Add(this.Exe); + command.Add(this.Verbose); + command.Add(this.Json); + command.Add(this.Si); if (includeThreads) - command.Add(Threads); + command.Add(this.Threads); } - public CommonOptions Parse(ParseResult parseResult) + public CommonOptions Parse(ParseResult parseResult) => new() { - return new CommonOptions - { - ExePath = parseResult.GetRequiredValue(Exe).FullName, - Verbose = parseResult.GetValue(Verbose), - Json = parseResult.GetValue(Json), - SizeFormat = parseResult.GetValue(Si) ? SizeFormat.SI : SizeFormat.IEC, - Threads = parseResult.GetValue(Threads), - }; - } + ExePath = parseResult.GetRequiredValue(this.Exe).FullName, + Verbose = parseResult.GetValue(this.Verbose), + Json = parseResult.GetValue(this.Json), + SizeFormat = parseResult.GetValue(this.Si) ? SizeFormat.SI : SizeFormat.IEC, + Threads = parseResult.GetValue(this.Threads), + }; } diff --git a/CodeWalker.Cli/DiffHandler.cs b/CodeWalker.Cli/DiffHandler.cs index 21571d7d2..5eb169b57 100644 --- a/CodeWalker.Cli/DiffHandler.cs +++ b/CodeWalker.Cli/DiffHandler.cs @@ -179,7 +179,7 @@ Json.DiffResult ErrorResult(string[] errorMessages) => // Result per common path: null = unchanged, non-null = modified entry bool[] isModifiedArr = new bool[commonPaths.Length]; - Parallel.For( + _ = Parallel.For( 0, commonPaths.Length, new ParallelOptions { MaxDegreeOfParallelism = options.Common.Threads }, diff --git a/CodeWalker.Cli/ExportAudioHandler.cs b/CodeWalker.Cli/ExportAudioHandler.cs index fcc39f9c9..388008c2e 100644 --- a/CodeWalker.Cli/ExportAudioHandler.cs +++ b/CodeWalker.Cli/ExportAudioHandler.cs @@ -70,7 +70,7 @@ bool noOverwrite if (!dirCreated) { if (!Directory.Exists(fileOutputDir)) - Directory.CreateDirectory(fileOutputDir); + _ = Directory.CreateDirectory(fileOutputDir); dirCreated = true; } File.WriteAllBytes(midiPath, stream.MidiChunk.Data); @@ -85,7 +85,7 @@ bool noOverwrite if (!dirCreated) { if (!Directory.Exists(fileOutputDir)) - Directory.CreateDirectory(fileOutputDir); + _ = Directory.CreateDirectory(fileOutputDir); dirCreated = true; } File.WriteAllBytes(wavPath, wav); diff --git a/CodeWalker.Cli/ExportHandler.cs b/CodeWalker.Cli/ExportHandler.cs index 7da6d6673..c9b3ef794 100644 --- a/CodeWalker.Cli/ExportHandler.cs +++ b/CodeWalker.Cli/ExportHandler.cs @@ -39,24 +39,21 @@ internal sealed class ExportCommandOptions public void AddTo(Command command) { - _rpfOpts.AddTo(command); - command.Add(Output); - command.Add(DryRun); - command.Add(NoOverwrite); - command.Add(Progress); + this._rpfOpts.AddTo(command); + command.Add(this.Output); + command.Add(this.DryRun); + command.Add(this.NoOverwrite); + command.Add(this.Progress); } - public ExportOptions Parse(ParseResult parseResult) + public ExportOptions Parse(ParseResult parseResult) => new() { - return new ExportOptions - { - Rpf = _rpfOpts.Parse(parseResult), - OutputPath = parseResult.GetValue(Output)?.FullName ?? Directory.GetCurrentDirectory(), - DryRun = parseResult.GetValue(DryRun), - NoOverwrite = parseResult.GetValue(NoOverwrite), - Progress = parseResult.GetValue(Progress), - }; - } + Rpf = this._rpfOpts.Parse(parseResult), + OutputPath = parseResult.GetValue(this.Output)?.FullName ?? Directory.GetCurrentDirectory(), + DryRun = parseResult.GetValue(this.DryRun), + NoOverwrite = parseResult.GetValue(this.NoOverwrite), + Progress = parseResult.GetValue(this.Progress), + }; } internal static class ExportHandler diff --git a/CodeWalker.Cli/ExportService.cs b/CodeWalker.Cli/ExportService.cs index d2a16ec77..84fc8d6b3 100644 --- a/CodeWalker.Cli/ExportService.cs +++ b/CodeWalker.Cli/ExportService.cs @@ -186,7 +186,7 @@ Json.ExportResult ErrorResult(string[] errorMessages) => if (!options.DryRun && !Directory.Exists(outputDir)) { - Directory.CreateDirectory(outputDir); + _ = Directory.CreateDirectory(outputDir); } List<(RpfFile rpf, RpfFileEntry entry)> filesToExport = RpfService.CollectFiles( @@ -210,7 +210,7 @@ Json.ExportResult ErrorResult(string[] errorMessages) => ) ) { - Parallel.For( + _ = Parallel.For( 0, filesToExport.Count, new ParallelOptions { MaxDegreeOfParallelism = options.Rpf.Threads }, diff --git a/CodeWalker.Cli/ExportTextHandler.cs b/CodeWalker.Cli/ExportTextHandler.cs index a8d2344d1..0017c7947 100644 --- a/CodeWalker.Cli/ExportTextHandler.cs +++ b/CodeWalker.Cli/ExportTextHandler.cs @@ -89,7 +89,7 @@ bool noOverwrite if (!Directory.Exists(fileOutputDir)) { - Directory.CreateDirectory(fileOutputDir); + _ = Directory.CreateDirectory(fileOutputDir); } File.WriteAllText(outputPath, text, Encoding.UTF8); diff --git a/CodeWalker.Cli/ExportTexturesHandler.cs b/CodeWalker.Cli/ExportTexturesHandler.cs index e5974ff57..1d12c3bf3 100644 --- a/CodeWalker.Cli/ExportTexturesHandler.cs +++ b/CodeWalker.Cli/ExportTexturesHandler.cs @@ -70,7 +70,7 @@ bool noOverwrite if (!dirCreated) { if (!Directory.Exists(fileOutputDir)) - Directory.CreateDirectory(fileOutputDir); + _ = Directory.CreateDirectory(fileOutputDir); dirCreated = true; } diff --git a/CodeWalker.Cli/ExportXmlHandler.cs b/CodeWalker.Cli/ExportXmlHandler.cs index ecc8eaccc..13b759a87 100644 --- a/CodeWalker.Cli/ExportXmlHandler.cs +++ b/CodeWalker.Cli/ExportXmlHandler.cs @@ -50,7 +50,7 @@ bool noOverwrite if (!string.IsNullOrEmpty(fileOutputDir) && !Directory.Exists(fileOutputDir)) { - Directory.CreateDirectory(fileOutputDir); + _ = Directory.CreateDirectory(fileOutputDir); } string outputPath = Path.Combine(fileOutputDir, filename); diff --git a/CodeWalker.Cli/ExtractHandler.cs b/CodeWalker.Cli/ExtractHandler.cs index c45dda511..7fadf724e 100644 --- a/CodeWalker.Cli/ExtractHandler.cs +++ b/CodeWalker.Cli/ExtractHandler.cs @@ -119,7 +119,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => if (!options.DryRun && !Directory.Exists(outputDir)) { - Directory.CreateDirectory(outputDir); + _ = Directory.CreateDirectory(outputDir); } // Collect files first for progress bar @@ -147,7 +147,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => ) ) { - Parallel.For( + _ = Parallel.For( 0, filesToExtract.Count, new ParallelOptions { MaxDegreeOfParallelism = options.Rpf.Threads }, @@ -189,7 +189,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => } else if (options.NoOverwrite && File.Exists(outputPath)) { - Interlocked.Increment(ref overwriteSkipped); + _ = Interlocked.Increment(ref overwriteSkipped); if (options.Rpf.Verbose && !options.Rpf.Json && !options.Progress) { lock (consoleLock) @@ -205,7 +205,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => { if (!string.IsNullOrEmpty(fileDir) && !Directory.Exists(fileDir)) { - Directory.CreateDirectory(fileDir); + _ = Directory.CreateDirectory(fileDir); } if (options.Rpf.Verbose && !options.Rpf.Json && !options.Progress) diff --git a/CodeWalker.Cli/Gen9Handler.cs b/CodeWalker.Cli/Gen9Handler.cs index c6b5d8c25..236a21efe 100644 --- a/CodeWalker.Cli/Gen9Handler.cs +++ b/CodeWalker.Cli/Gen9Handler.cs @@ -149,7 +149,7 @@ Json.Gen9Result ErrorResult(string[] errorMessages) => { if (!Directory.Exists(options.OutputPath)) { - Directory.CreateDirectory(options.OutputPath); + _ = Directory.CreateDirectory(options.OutputPath); } string inputFolder = options.InputPath; @@ -210,7 +210,7 @@ Json.Gen9Result ErrorResult(string[] errorMessages) => object consoleLock = new(); - Parallel.For( + _ = Parallel.For( 0, filePaths.Count, new ParallelOptions { MaxDegreeOfParallelism = options.Common.Threads }, @@ -249,7 +249,7 @@ Json.Gen9Result ErrorResult(string[] errorMessages) => string? outDir = Path.GetDirectoryName(outPath); if (!string.IsNullOrEmpty(outDir) && !Directory.Exists(outDir)) { - Directory.CreateDirectory(outDir); + _ = Directory.CreateDirectory(outDir); } string ext = Path.GetExtension(path).ToLowerInvariant(); @@ -387,7 +387,7 @@ out bool wasConverted string? outDir = Path.GetDirectoryName(outPath); if (!string.IsNullOrEmpty(outDir) && !Directory.Exists(outDir)) { - Directory.CreateDirectory(outDir); + _ = Directory.CreateDirectory(outDir); } ProcessRpfFile( @@ -600,7 +600,7 @@ out bool wasConverted continue; } - RpfFile.CreateFile(dir, name, dataOut, true); + _ = RpfFile.CreateFile(dir, name, dataOut, true); converted++; files.Add(new Json.Gen9FileEntry { Path = rfe.Path, Status = "converted" }); changed = true; @@ -616,7 +616,7 @@ out bool wasConverted if (currentRpf.Parent != null) { - changedParents.Add(currentRpf.Parent); + _ = changedParents.Add(currentRpf.Parent); } } } diff --git a/CodeWalker.Cli/Helpers/Filter.cs b/CodeWalker.Cli/Helpers/Filter.cs index 4bda4880d..c82de4eea 100644 --- a/CodeWalker.Cli/Helpers/Filter.cs +++ b/CodeWalker.Cli/Helpers/Filter.cs @@ -72,10 +72,9 @@ private static bool MatchesGlob(string input, string pattern) // Handle extension-only patterns (e.g., ".ydr" or "ydr" without wildcards) if (!pattern.Contains('*', StringComparison.Ordinal) && !pattern.Contains('?', StringComparison.Ordinal)) { - if (pattern.StartsWith('.')) - return input.EndsWith(pattern, StringComparison.Ordinal); - else - return input.EndsWith($".{pattern}", StringComparison.Ordinal); + return pattern.StartsWith('.') + ? input.EndsWith(pattern, StringComparison.Ordinal) + : input.EndsWith($".{pattern}", StringComparison.Ordinal); } Regex regex = RegexCache.GetOrAdd( diff --git a/CodeWalker.Cli/Helpers/ProgressBar.cs b/CodeWalker.Cli/Helpers/ProgressBar.cs index 89ffd4de1..6efb61b5d 100644 --- a/CodeWalker.Cli/Helpers/ProgressBar.cs +++ b/CodeWalker.Cli/Helpers/ProgressBar.cs @@ -28,14 +28,14 @@ internal sealed class ProgressBar : IDisposable /// Terminal width used for padding and truncation when a custom writer is provided. public ProgressBar(int total, bool enabled, TextWriter? writer = null, int windowWidth = 120) { - _total = total; - _writer = writer ?? Console.Error; - _ownsConsole = writer is null; - _windowWidth = windowWidth; - _enabled = enabled && total > 0 && (!_ownsConsole || !Console.IsErrorRedirected); - if (_enabled) + this._total = total; + this._writer = writer ?? Console.Error; + this._ownsConsole = writer is null; + this._windowWidth = windowWidth; + this._enabled = enabled && total > 0 && (!this._ownsConsole || !Console.IsErrorRedirected); + if (this._enabled) { - if (_ownsConsole) + if (this._ownsConsole) { try { @@ -43,7 +43,7 @@ public ProgressBar(int total, bool enabled, TextWriter? writer = null, int windo } catch { } } - Render(); + this.Render(); } } @@ -54,18 +54,18 @@ public ProgressBar(int total, bool enabled, TextWriter? writer = null, int windo /// Optional current file being processed. public void Update(int current, string? currentFile = null) { - lock (_lock) + lock (this._lock) { - _current = current; - if (!_enabled) + this._current = current; + if (!this._enabled) return; // Throttle updates to avoid flickering - if ((DateTime.Now - _lastUpdate).TotalMilliseconds < 50 && current < _total) + if ((DateTime.Now - this._lastUpdate).TotalMilliseconds < 50 && current < this._total) return; - _lastUpdate = DateTime.Now; - Render(currentFile); + this._lastUpdate = DateTime.Now; + this.Render(currentFile); } } @@ -76,56 +76,56 @@ public void Update(int current, string? currentFile = null) /// Optional current file being processed. public void Increment(string? currentFile = null) { - lock (_lock) + lock (this._lock) { - _current++; - if (!_enabled) + this._current++; + if (!this._enabled) return; - if ((DateTime.Now - _lastUpdate).TotalMilliseconds < 50 && _current < _total) + if ((DateTime.Now - this._lastUpdate).TotalMilliseconds < 50 && this._current < this._total) return; - _lastUpdate = DateTime.Now; - Render(currentFile); + this._lastUpdate = DateTime.Now; + this.Render(currentFile); } } private void Render(string? currentFile = null) { - if (!_enabled) + if (!this._enabled) return; try { - double percent = _total > 0 ? (double)_current / _total : 0; - int filled = Math.Min((int)(percent * _barWidth), _barWidth); - int winWidth = _ownsConsole ? Console.WindowWidth : _windowWidth; + double percent = this._total > 0 ? (double)this._current / this._total : 0; + int filled = Math.Min((int)(percent * this._barWidth), this._barWidth); + int winWidth = this._ownsConsole ? Console.WindowWidth : this._windowWidth; - if (_ownsConsole) + if (this._ownsConsole) Console.SetCursorPosition(0, Console.CursorTop); - _writer.Write("["); - _writer.Write(new string('=', filled)); - if (filled < _barWidth) + this._writer.Write("["); + this._writer.Write(new string('=', filled)); + if (filled < this._barWidth) { - _writer.Write(">"); - _writer.Write(new string(' ', _barWidth - filled - 1)); + this._writer.Write(">"); + this._writer.Write(new string(' ', this._barWidth - filled - 1)); } - string stats = $"] {percent,6:P0} ({_current}/{_total})"; - _writer.Write(stats); + string stats = $"] {percent,6:P0} ({this._current}/{this._total})"; + this._writer.Write(stats); - int written = 1 + _barWidth + stats.Length; + int written = 1 + this._barWidth + stats.Length; if (!string.IsNullOrEmpty(currentFile)) { - int maxLen = Math.Max(10, winWidth - _barWidth - 30); + int maxLen = Math.Max(10, winWidth - this._barWidth - 30); string displayFile = currentFile!.Length > maxLen ? $"...{currentFile[(currentFile.Length - maxLen + 3)..]}" : currentFile; string fileText = $" {displayFile}"; - _writer.Write(fileText); + this._writer.Write(fileText); written += fileText.Length; } @@ -133,7 +133,7 @@ private void Render(string? currentFile = null) int remaining = winWidth - written - 1; if (remaining > 0) { - _writer.Write(new string(' ', remaining)); + this._writer.Write(new string(' ', remaining)); } } catch (Exception ex) @@ -148,12 +148,12 @@ private void Render(string? currentFile = null) /// public void Dispose() { - if (_enabled) + if (this._enabled) { try { - _writer.WriteLine(); - if (_ownsConsole) + this._writer.WriteLine(); + if (this._ownsConsole) Console.CursorVisible = true; } catch (Exception ex) diff --git a/CodeWalker.Cli/InspectHandler.cs b/CodeWalker.Cli/InspectHandler.cs index 8be561e1c..4055afe12 100644 --- a/CodeWalker.Cli/InspectHandler.cs +++ b/CodeWalker.Cli/InspectHandler.cs @@ -327,13 +327,13 @@ entry is RpfFileEntry fileEntry string? strExtMin = null; string? strExtMax = null; - if (file._CMapData.entitiesExtentsMin != default(Vector3)) + if (file._CMapData.entitiesExtentsMin != default) entExtMin = FormatVector3(file._CMapData.entitiesExtentsMin); - if (file._CMapData.entitiesExtentsMax != default(Vector3)) + if (file._CMapData.entitiesExtentsMax != default) entExtMax = FormatVector3(file._CMapData.entitiesExtentsMax); - if (file._CMapData.streamingExtentsMin != default(Vector3)) + if (file._CMapData.streamingExtentsMin != default) strExtMin = FormatVector3(file._CMapData.streamingExtentsMin); - if (file._CMapData.streamingExtentsMax != default(Vector3)) + if (file._CMapData.streamingExtentsMax != default) strExtMax = FormatVector3(file._CMapData.streamingExtentsMax); return new Json.YmapDetails @@ -504,10 +504,8 @@ private static void AddLod(List lods, string level, DrawableModel[ ); } - private static string FormatVector3(Vector3 v) - { - return $"{v.X:F2}, {v.Y:F2}, {v.Z:F2}"; - } + private static string FormatVector3(Vector3 v) => + $"{v.X:F2}, {v.Y:F2}, {v.Z:F2}"; private static void PrintTextResult(Json.InspectResult result, RpfOptions options) { @@ -574,13 +572,17 @@ private static void PrintTextResult(Json.InspectResult result, RpfOptions option Console.WriteLine($"Entities: {ymap.EntityCount}"); Console.WriteLine($"Car Generators: {ymap.CarGeneratorCount}"); if (ymap.EntitiesExtentsMin != null) + { Console.WriteLine( $"Entity Extents: [{ymap.EntitiesExtentsMin}] to [{ymap.EntitiesExtentsMax}]" ); + } if (ymap.StreamingExtentsMin != null) + { Console.WriteLine( $"Stream Extents: [{ymap.StreamingExtentsMin}] to [{ymap.StreamingExtentsMax}]" ); + } Console.WriteLine($"Scripted: {(ymap.IsScripted ? "yes" : "no")}"); break; diff --git a/CodeWalker.Cli/PackHandler.cs b/CodeWalker.Cli/PackHandler.cs index e7d209a23..4ea8d2770 100644 --- a/CodeWalker.Cli/PackHandler.cs +++ b/CodeWalker.Cli/PackHandler.cs @@ -149,7 +149,7 @@ Json.PackResult ErrorResult(string[] errorMessages) => string? outputDir = Path.GetDirectoryName(options.OutputPath); if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir)) { - Directory.CreateDirectory(outputDir); + _ = Directory.CreateDirectory(outputDir); } string outputFolder = outputDir ?? Directory.GetCurrentDirectory(); @@ -303,7 +303,7 @@ ref errors Console.Error.WriteLine($"Adding file: {fileName} ({data.Length} bytes)"); } - RpfFile.CreateFile(parentDir, fileName, data); + _ = RpfFile.CreateFile(parentDir, fileName, data); totalFiles++; totalSize += data.Length; progress.Increment(fileName); diff --git a/CodeWalker.Cli/Polyfills.cs b/CodeWalker.Cli/Polyfills.cs index e785a3627..e1187f700 100644 --- a/CodeWalker.Cli/Polyfills.cs +++ b/CodeWalker.Cli/Polyfills.cs @@ -81,10 +81,10 @@ public static bool EndsWith(this string s, char value, StringComparison comparis } // append the unmodified portion of search space - result.Append(searchSpace[..index]); + _ = result.Append(searchSpace[..index]); // append the replacement - result.Append(newValue); + _ = result.Append(newValue); searchSpace = searchSpace[(index + matchLength)..]; hasDoneAnyReplacements = true; @@ -102,7 +102,7 @@ public static bool EndsWith(this string s, char value, StringComparison comparis // Append what remains of the search space, then allocate the new string. - result.Append(searchSpace); + _ = result.Append(searchSpace); return result.ToString(); } diff --git a/CodeWalker.Cli/RpfOptions.cs b/CodeWalker.Cli/RpfOptions.cs index 5b72f4774..d7482aff6 100644 --- a/CodeWalker.Cli/RpfOptions.cs +++ b/CodeWalker.Cli/RpfOptions.cs @@ -53,25 +53,25 @@ internal sealed class RpfCommandOptions public void AddTo(Command command) { - command.Add(Rpf); - _commonOpts.AddTo(command); - command.Add(Gen9); - command.Add(Filter); - command.Add(Recursive); + command.Add(this.Rpf); + this._commonOpts.AddTo(command); + command.Add(this.Gen9); + command.Add(this.Filter); + command.Add(this.Recursive); } public RpfOptions Parse(ParseResult parseResult) { - CommonOptions common = _commonOpts.Parse(parseResult); + CommonOptions common = this._commonOpts.Parse(parseResult); return new RpfOptions { - RpfPath = parseResult.GetRequiredValue(Rpf).FullName, + RpfPath = parseResult.GetRequiredValue(this.Rpf).FullName, ExePath = common.ExePath, - Gen9 = parseResult.GetValue(Gen9), - Filters = Helpers.Filter.Normalize(parseResult.GetValue(Filter)), + Gen9 = parseResult.GetValue(this.Gen9), + Filters = Helpers.Filter.Normalize(parseResult.GetValue(this.Filter)), Verbose = common.Verbose, Json = common.Json, - Recursive = parseResult.GetValue(Recursive), + Recursive = parseResult.GetValue(this.Recursive), Threads = common.Threads, SizeFormat = common.SizeFormat, }; diff --git a/CodeWalker.Cli/RpfService.cs b/CodeWalker.Cli/RpfService.cs index df85a80db..072351704 100644 --- a/CodeWalker.Cli/RpfService.cs +++ b/CodeWalker.Cli/RpfService.cs @@ -73,10 +73,8 @@ internal static class RpfService /// /// Loads GTA V encryption keys from the installation directory. /// - public static void LoadKeys(string exePath, bool gen9) - { + public static void LoadKeys(string exePath, bool gen9) => GTA5Keys.LoadFromPath(exePath, gen9); - } /// /// Recursively collects file entries from an RPF archive, applying glob filters. @@ -160,15 +158,12 @@ private static void CountNonRpfFilesRecursive(RpfFile rpf, bool recursive, ref i /// /// Returns the file type string for a given RPF file entry. /// - public static string GetFileType(RpfFileEntry fileEntry) + public static string GetFileType(RpfFileEntry fileEntry) => fileEntry switch { - return fileEntry switch - { - RpfResourceFileEntry => "resource", - RpfBinaryFileEntry => "binary", - _ => "unknown", - }; - } + RpfResourceFileEntry => "resource", + RpfBinaryFileEntry => "binary", + _ => "unknown", + }; /// /// Validates inputs, loads encryption keys, and prints status to stderr. @@ -217,9 +212,11 @@ List errorMessages ); if (!json) + { Console.Error.WriteLine( $"Found {rpf.GrandTotalFileCount} files in {rpf.GrandTotalRpfCount} archive(s)" ); + } return rpf; } diff --git a/CodeWalker.Cli/SearchHandler.cs b/CodeWalker.Cli/SearchHandler.cs index 163be697b..5b06b5b4c 100644 --- a/CodeWalker.Cli/SearchHandler.cs +++ b/CodeWalker.Cli/SearchHandler.cs @@ -132,7 +132,7 @@ out uint hash // Match in parallel Json.SearchMatch?[] results = new Json.SearchMatch?[allEntries.Count]; - Parallel.For( + _ = Parallel.For( 0, allEntries.Count, new ParallelOptions { MaxDegreeOfParallelism = options.Threads }, @@ -227,10 +227,9 @@ out uint hash } } - private static bool HasGlobChars(string s) - { - return s.Contains('*', StringComparison.Ordinal) || s.Contains('?', StringComparison.Ordinal); - } + private static bool HasGlobChars(string s) => + s.Contains('*', StringComparison.Ordinal) || + s.Contains('?', StringComparison.Ordinal); private static void CollectAllEntries(RpfFile rpf, bool recursive, List entries) { diff --git a/CodeWalker.Cli/ValidateHandler.cs b/CodeWalker.Cli/ValidateHandler.cs index 5a880bffd..bb69a8130 100644 --- a/CodeWalker.Cli/ValidateHandler.cs +++ b/CodeWalker.Cli/ValidateHandler.cs @@ -99,7 +99,7 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => using (ProgressBar progress = new(entries.Count, options.Progress && !options.Rpf.Json)) { - Parallel.For( + _ = Parallel.For( 0, entries.Count, new ParallelOptions { MaxDegreeOfParallelism = options.Rpf.Threads }, @@ -250,10 +250,7 @@ string ext YtdFile file = RpfFile.GetFile(fileEntry); if (file == null) return ("error", "Failed to load YTD file"); - if ( - file.TextureDict?.Textures?.data_items == null - || file.TextureDict.Textures.data_items.Length == 0 - ) + if (file.TextureDict?.Textures?.data_items == null || file.TextureDict.Textures.data_items.Length == 0) return ("warning", "Texture dictionary is empty"); return ("valid", null); } From 3c61d25ed62eaf58dbc68c8cc78d15113df64170 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:26:32 +0200 Subject: [PATCH 13/45] fix(cli): correct the net48 string polyfills The polyfills were written against APIs the framework they stand in for does not have. string.IndexOf(char, StringComparison) arrived in .NET Core 2.1; on net48 that call binds to IndexOf(char, int) and does not compile. CompareInfo.IndexOf with spans and an out match length is newer still. Contains(char) converts to a string before searching. ReplaceCore takes strings and recovers the match length through a helper, since the overload that reports it is not available here. The char overloads of StartsWith and EndsWith that took a StringComparison are gone: a comparison mode says nothing about a single character matched at one position, and nothing called them. --- CodeWalker.Cli/Polyfills.cs | 54 +++++++++++++------ CodeWalker.Cli/Tests/ExportServiceTests.cs | 4 +- .../Tests/Helpers/ProgressBarTests.cs | 20 +++---- CodeWalker.Cli/Tests/PolyfillsTests.cs | 34 ++---------- CodeWalker.Cli/Tests/RpfServiceTests.cs | 24 ++++----- 5 files changed, 64 insertions(+), 72 deletions(-) diff --git a/CodeWalker.Cli/Polyfills.cs b/CodeWalker.Cli/Polyfills.cs index e1187f700..b76401f55 100644 --- a/CodeWalker.Cli/Polyfills.cs +++ b/CodeWalker.Cli/Polyfills.cs @@ -23,14 +23,14 @@ public static bool Contains(this string s, string value, StringComparison compar public static bool Contains(this string s, char value) { #pragma warning disable CA2249 // Consider using 'string.Contains' instead of 'string.IndexOf'... this is the implementation of Contains! - return s.IndexOf(value, StringComparison.Ordinal) >= 0; + return s.IndexOf(value.ToString(), StringComparison.Ordinal) >= 0; #pragma warning restore CA2249 } public static bool Contains(this string s, char value, StringComparison comparisonType) { #pragma warning disable CA2249 // Consider using 'string.Contains' instead of 'string.IndexOf'... this is the implementation of Contains! - return s.IndexOf(value, comparisonType) >= 0; + return s.IndexOf(value.ToString(), comparisonType) >= 0; #pragma warning restore CA2249 } @@ -39,29 +39,19 @@ public static bool StartsWith(this string s, char value) return s.Length > 0 && s[0] == value; } - public static bool StartsWith(this string s, char value, StringComparison comparisonType) - { - return s.StartsWith(value.ToString(), comparisonType); - } - public static bool EndsWith(this string s, char value) { return s.Length > 0 && s[^1] == value; } - public static bool EndsWith(this string s, char value, StringComparison comparisonType) - { - return s.EndsWith(value.ToString(), comparisonType); - } - private static string? ReplaceCore( - ReadOnlySpan searchSpace, - ReadOnlySpan oldValue, - ReadOnlySpan newValue, + string searchSpace, + string oldValue, + string? newValue, CompareInfo compareInfo, CompareOptions options) { - Debug.Assert(!oldValue.IsEmpty); + Debug.Assert(!string.IsNullOrEmpty(oldValue)); Debug.Assert(compareInfo != null); StringBuilder result = new(); @@ -70,7 +60,8 @@ public static bool EndsWith(this string s, char value, StringComparison comparis while (true) { - int index = compareInfo.IndexOf(searchSpace, oldValue, options, out int matchLength); + int index = compareInfo!.IndexOf(searchSpace, oldValue, options); + int matchLength = FindMatchLength(compareInfo, searchSpace, index, oldValue, options); // There's the possibility that 'oldValue' has zero collation weight (empty string equivalent). // If this is the case, we behave as if there are no more substitutions to be made. @@ -106,6 +97,35 @@ public static bool EndsWith(this string s, char value, StringComparison comparis return result.ToString(); } + private static int FindMatchLength( + CompareInfo compareInfo, + string source, + int index, + string value, + CompareOptions options) + { + if (index < 0) + return 0; + + // Fast path: most matches consume exactly value.Length characters + if (index + value.Length <= source.Length + && compareInfo.Compare(source, index, value.Length, value, 0, value.Length, options) == 0) + { + return value.Length; + } + + // Slow path: cultural normalization means the matched span differs + // from value.Length (e.g. zero-weight characters like \0) + int maxLen = source.Length - index; + for (int len = 1; len <= maxLen; len++) + { + if (compareInfo.Compare(source, index, len, value, 0, value.Length, options) == 0) + return len; + } + + return value.Length; // fallback (should be unreachable if IndexOf found a match) + } + public static string Replace(this string s, string oldValue, string? newValue, StringComparison comparisonType) { if (comparisonType == StringComparison.Ordinal) diff --git a/CodeWalker.Cli/Tests/ExportServiceTests.cs b/CodeWalker.Cli/Tests/ExportServiceTests.cs index 35e5f92f3..d5b243d67 100644 --- a/CodeWalker.Cli/Tests/ExportServiceTests.cs +++ b/CodeWalker.Cli/Tests/ExportServiceTests.cs @@ -250,7 +250,7 @@ public void OutputDirectory_ComputedFromBackslashPath() { string? capturedOutputDir = null; - ExportService.ProcessSingleFile( + _ = ExportService.ProcessSingleFile( MakeEntry("x64\\levels\\gta5\\vehicles.rpf\\adder.ydr", "adder.ydr"), data: [1], outputDir: "/out", @@ -305,7 +305,7 @@ public void EmptyResults_AllZeros_OnlyScanErrors() Assert.Equal(0, agg.Skipped); Assert.Equal(0, agg.Errors); Assert.Empty(agg.Files); - Assert.Single(agg.ErrorMessages); + _ = Assert.Single(agg.ErrorMessages); Assert.Equal("scan error 1", agg.ErrorMessages[0]); } diff --git a/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs b/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs index 1aa2414cb..e45a67d48 100644 --- a/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs +++ b/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs @@ -81,7 +81,7 @@ public void Render_at_50_percent_has_half_filled_bar() { StringWriter sw = new(); using ProgressBar bar = new(100, enabled: true, sw, windowWidth: 120); - sw.GetStringBuilder().Clear(); + _ = sw.GetStringBuilder().Clear(); bar.Update(100 / 2); // 50 == total bypasses throttle? No, 50 < 100. Need to reset throttle. ResetThrottle(bar); bar.Update(50); @@ -96,7 +96,7 @@ public void Render_at_100_percent_has_full_bar_no_cursor() { StringWriter sw = new(); using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 120); - sw.GetStringBuilder().Clear(); + _ = sw.GetStringBuilder().Clear(); bar.Update(10); // == total, bypasses throttle string output = sw.ToString(); Assert.Contains(new string('=', 40) + "]", output); @@ -111,7 +111,7 @@ public void Render_shows_current_file() { StringWriter sw = new(); using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 120); - sw.GetStringBuilder().Clear(); + _ = sw.GetStringBuilder().Clear(); bar.Update(10, "textures/player.ytd"); // bypasses throttle at total string output = sw.ToString(); Assert.Contains("textures/player.ytd", output); @@ -123,7 +123,7 @@ public void Render_truncates_long_file_with_ellipsis() StringWriter sw = new(); // windowWidth=80 → maxLen = Max(10, 80-40-30) = 10 using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 80); - sw.GetStringBuilder().Clear(); + _ = sw.GetStringBuilder().Clear(); bar.Update(10, "very/long/path/to/some/deeply/nested/file.ytd"); string output = sw.ToString(); Assert.Contains("...", output); @@ -135,7 +135,7 @@ public void Render_shows_short_file_without_truncation() { StringWriter sw = new(); using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 200); - sw.GetStringBuilder().Clear(); + _ = sw.GetStringBuilder().Clear(); bar.Update(10, "short.ytd"); string output = sw.ToString(); Assert.Contains("short.ytd", output); @@ -181,7 +181,7 @@ public void Throttle_skips_rapid_updates() { StringWriter sw = new(); using ProgressBar bar = new(100, enabled: true, sw, windowWidth: 80); - sw.GetStringBuilder().Clear(); + _ = sw.GetStringBuilder().Clear(); // Rapid updates — only the first and last should render for (int i = 1; i <= 50; i++) bar.Update(i); @@ -197,7 +197,7 @@ public void Throttle_bypassed_at_total() { StringWriter sw = new(); using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 80); - sw.GetStringBuilder().Clear(); + _ = sw.GetStringBuilder().Clear(); // Update to total always renders even within throttle window bar.Update(10); Assert.Contains("(10/10)", sw.ToString()); @@ -208,7 +208,7 @@ public void Throttle_reset_allows_render() { StringWriter sw = new(); using ProgressBar bar = new(100, enabled: true, sw, windowWidth: 80); - sw.GetStringBuilder().Clear(); + _ = sw.GetStringBuilder().Clear(); ResetThrottle(bar); bar.Update(25); Assert.Contains("(25/100)", sw.ToString()); @@ -249,7 +249,7 @@ public void Concurrent_increments_are_thread_safe() StringWriter sw = new(); using ProgressBar bar = new(total, enabled: true, sw); - Parallel.For(0, total, _ => bar.Increment()); + _ = Parallel.For(0, total, _ => bar.Increment()); Assert.Equal(total, GetCurrent(bar)); } @@ -260,7 +260,7 @@ public void Concurrent_updates_do_not_throw() StringWriter sw = new(); using ProgressBar bar = new(1000, enabled: true, sw); - Parallel.For(0, 1000, i => bar.Update(i, $"file_{i}.txt")); + _ = Parallel.For(0, 1000, i => bar.Update(i, $"file_{i}.txt")); int current = GetCurrent(bar); Assert.InRange(current, 0, 999); diff --git a/CodeWalker.Cli/Tests/PolyfillsTests.cs b/CodeWalker.Cli/Tests/PolyfillsTests.cs index 106ed6873..64eabe079 100644 --- a/CodeWalker.Cli/Tests/PolyfillsTests.cs +++ b/CodeWalker.Cli/Tests/PolyfillsTests.cs @@ -127,20 +127,6 @@ public void StartsWith_Char_MatchesStringOverload() $"StartsWith(\"{Esc(s)}\", '{c}')"); } - // ─── StartsWith(char, StringComparison) ───────────────────────── - - [Fact] - public void StartsWith_Char_Comparison_MatchesStringOverload() - { - foreach (string s in Corpus) - foreach (char c in Chars) - foreach (StringComparison cmp in AllComparisons) - AssertBool( - s.StartsWith(c.ToString(), cmp), - StringExtensions.StartsWith(s, c, cmp), - $"StartsWith(\"{Esc(s)}\", '{c}', {cmp})"); - } - // ─── EndsWith(char) ───────────────────────────────────────────── // Verify against string-based EndsWith with Ordinal comparison. @@ -155,20 +141,6 @@ public void EndsWith_Char_MatchesStringOverload() $"EndsWith(\"{Esc(s)}\", '{c}')"); } - // ─── EndsWith(char, StringComparison) ─────────────────────────── - - [Fact] - public void EndsWith_Char_Comparison_MatchesStringOverload() - { - foreach (string s in Corpus) - foreach (char c in Chars) - foreach (StringComparison cmp in AllComparisons) - AssertBool( - s.EndsWith(c.ToString(), cmp), - StringExtensions.EndsWith(s, c, cmp), - $"EndsWith(\"{Esc(s)}\", '{c}', {cmp})"); - } - // ─── Replace(string, string?, StringComparison) ───────────────── // Ordinal path delegates to built-in; non-Ordinal uses ReplaceCore. @@ -213,13 +185,13 @@ public sealed class StringExtensionsUnitTests [Fact] public void Replace_NullOldValue_Throws() { - Assert.Throws(() => StringExtensions.Replace("input", null!, "new", StringComparison.Ordinal)); + _ = Assert.Throws(() => StringExtensions.Replace("input", null!, "new", StringComparison.Ordinal)); } [Fact] public void Replace_EmptyOldValue_Throws() { - Assert.Throws(() => StringExtensions.Replace("input", "", "new", StringComparison.Ordinal)); + _ = Assert.Throws(() => StringExtensions.Replace("input", "", "new", StringComparison.Ordinal)); } [Fact] @@ -232,6 +204,6 @@ public void Replace_NullNewValue_DoesNotThrow() [Fact] public void Replace_UnsupportedComparison_Throws() { - Assert.Throws(() => StringExtensions.Replace("input", "in", "new", (StringComparison)999)); + _ = Assert.Throws(() => StringExtensions.Replace("input", "in", "new", (StringComparison)999)); } } diff --git a/CodeWalker.Cli/Tests/RpfServiceTests.cs b/CodeWalker.Cli/Tests/RpfServiceTests.cs index 5e127ec56..02f38caa7 100644 --- a/CodeWalker.Cli/Tests/RpfServiceTests.cs +++ b/CodeWalker.Cli/Tests/RpfServiceTests.cs @@ -14,7 +14,7 @@ public sealed class RpfServiceTests private static string CreateTempDir() { string dir = Path.Combine(Path.GetTempPath(), "cw_test_" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(dir); + _ = Directory.CreateDirectory(dir); return dir; } @@ -163,7 +163,7 @@ public void CollectFiles_ReturnsFileEntries() RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = [entry] }; List<(RpfFile rpf, RpfFileEntry entry)> files = RpfService.CollectFiles(rpf, null, recursive: false); - Assert.Single(files); + _ = Assert.Single(files); Assert.Same(entry, files[0].entry); } @@ -175,7 +175,7 @@ public void CollectFiles_SkipsRpfEntries() RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = [rpfEntry, fileEntry] }; List<(RpfFile rpf, RpfFileEntry entry)> files = RpfService.CollectFiles(rpf, null, recursive: false); - Assert.Single(files); + _ = Assert.Single(files); Assert.Equal("test.ydr", files[0].entry.Name); } @@ -190,7 +190,7 @@ public void CollectFiles_SkipsDirectoryEntries() }; List<(RpfFile rpf, RpfFileEntry entry)> files = RpfService.CollectFiles(rpf, null, recursive: false); - Assert.Single(files); + _ = Assert.Single(files); } [Fact] @@ -201,7 +201,7 @@ public void CollectFiles_AppliesFilter() RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = [e1, e2] }; List<(RpfFile rpf, RpfFileEntry entry)> files = RpfService.CollectFiles(rpf, ["*.ydr"], recursive: false); - Assert.Single(files); + _ = Assert.Single(files); Assert.Equal("test.ydr", files[0].entry.Name); } @@ -232,7 +232,7 @@ public void CollectFiles_NonRecursive_ExcludesChildren() }; List<(RpfFile rpf, RpfFileEntry entry)> files = RpfService.CollectFiles(parent, null, recursive: false); - Assert.Single(files); + _ = Assert.Single(files); Assert.Equal("a.ydr", files[0].entry.Name); } @@ -248,7 +248,7 @@ public void CollectFiles_Recursive_ReturnsCorrectRpfRef() }; List<(RpfFile rpf, RpfFileEntry entry)> files = RpfService.CollectFiles(parent, null, recursive: true); - Assert.Single(files); + _ = Assert.Single(files); Assert.Same(child, files[0].rpf); } @@ -351,7 +351,7 @@ public void ReportError_Json_WritesToStdout() StringWriter sw = new(); Console.SetOut(sw); Console.SetError(new StringWriter()); - RpfService.ReportError("test error", json: true, MakeBaseResult()); + _ = RpfService.ReportError("test error", json: true, MakeBaseResult()); string output = sw.ToString(); Assert.Contains("\"success\": false", output); Assert.Contains("test error", output); @@ -371,7 +371,7 @@ public void ReportError_Json_PreservesExistingErrors() { StringWriter sw = new(); Console.SetOut(sw); - RpfService.ReportError("new error", json: true, MakeBaseResult(["old error"])); + _ = RpfService.ReportError("new error", json: true, MakeBaseResult(["old error"])); string output = sw.ToString(); Assert.Contains("old error", output); Assert.Contains("new error", output); @@ -390,7 +390,7 @@ public void ReportError_Text_WritesToStderr() StringWriter stderr = new(); Console.SetOut(stdout); Console.SetError(stderr); - RpfService.ReportError("test error", json: false, MakeBaseResult()); + _ = RpfService.ReportError("test error", json: false, MakeBaseResult()); Assert.Contains("Error: test error", stderr.ToString()); Assert.Equal("", stdout.ToString()); } @@ -409,7 +409,7 @@ public void ReportError_Text_IncludesStackTrace() { StringWriter stderr = new(); Console.SetError(stderr); - RpfService.ReportError("err", json: false, MakeBaseResult(), "at Foo.Bar()"); + _ = RpfService.ReportError("err", json: false, MakeBaseResult(), "at Foo.Bar()"); Assert.Contains("at Foo.Bar()", stderr.ToString()); } finally { Console.SetError(origErr); } @@ -423,7 +423,7 @@ public void ReportError_Text_OmitsStackTrace_WhenNull() { StringWriter stderr = new(); Console.SetError(stderr); - RpfService.ReportError("err", json: false, MakeBaseResult()); + _ = RpfService.ReportError("err", json: false, MakeBaseResult()); Assert.DoesNotContain("at ", stderr.ToString()); } finally { Console.SetError(origErr); } From aa3d63b7a6752a85c995da9cac2a2639bc3402e3 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:26:32 +0200 Subject: [PATCH 14/45] refactor(cli): adopt LINQ and handle Ctrl+C Hand-written accumulate loops over rpf.AllEntries appeared in most handlers, each rebuilding the same filter and the same running totals. They are LINQ now, which is shorter and states what is being counted. Ctrl+C was not handled at all, so interrupting a long walk over a large archive killed the process wherever it happened to be, including part way through writing an output file. Program installs a CancellationTokenSource on CancelKeyPress and every command takes the token; the collection loops and the Parallel.For options observe it. inspect's per-type details were built as an untyped object, so the JSON shape depended on which branch ran and nothing checked the field names. They are records now. The audio exporter treats a stream whose hash is 0 as metadata with no playable data and skips it. That was unexplained; there is a comment saying why. Copying a file over an existing one deletes and re-copies, which is two operations and a window where neither exists; File.Copy overwrites in one. --- CodeWalker.Cli/DiffHandler.cs | 79 ++++----- CodeWalker.Cli/ExportAudioHandler.cs | 12 +- CodeWalker.Cli/ExportHandler.cs | 11 +- CodeWalker.Cli/ExportService.cs | 8 +- CodeWalker.Cli/ExportTextHandler.cs | 5 +- CodeWalker.Cli/ExportTexturesHandler.cs | 8 +- CodeWalker.Cli/ExportXmlHandler.cs | 5 +- CodeWalker.Cli/ExtractHandler.cs | 14 +- CodeWalker.Cli/Gen9Handler.cs | 53 +++--- CodeWalker.Cli/HashHandler.cs | 9 +- CodeWalker.Cli/InspectHandler.cs | 213 +++++++++--------------- CodeWalker.Cli/Json/InspectResult.cs | 32 ++-- CodeWalker.Cli/ListHandler.cs | 9 +- CodeWalker.Cli/PackHandler.cs | 19 ++- CodeWalker.Cli/Program.cs | 42 +++-- CodeWalker.Cli/RpfService.cs | 21 +-- CodeWalker.Cli/SearchHandler.cs | 19 +-- CodeWalker.Cli/StatHandler.cs | 9 +- CodeWalker.Cli/TreeHandler.cs | 61 +++---- CodeWalker.Cli/ValidateHandler.cs | 53 ++---- 20 files changed, 319 insertions(+), 363 deletions(-) diff --git a/CodeWalker.Cli/DiffHandler.cs b/CodeWalker.Cli/DiffHandler.cs index 5eb169b57..d139b2e7b 100644 --- a/CodeWalker.Cli/DiffHandler.cs +++ b/CodeWalker.Cli/DiffHandler.cs @@ -2,7 +2,9 @@ using System.Collections.Generic; using System.CommandLine; using System.IO; +using System.Linq; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using CodeWalker.Cli.Helpers; @@ -21,7 +23,7 @@ internal sealed record DiffOptions internal static class DiffHandler { - public static Command CreateCommand() + public static Command CreateCommand(CancellationToken cancellationToken = default) { CommonCommandOptions commonOpts = new(); Option leftOption = new("--left", "-l") @@ -66,13 +68,13 @@ public static Command CreateCommand() Gen9 = parseResult.GetValue(gen9Option), Recursive = parseResult.GetValue(recursiveOption), }; - return Execute(options); + return Execute(options, cancellationToken); }); return command; } - public static int Execute(DiffOptions options) + public static int Execute(DiffOptions options, CancellationToken cancellationToken = default) { Json.DiffResult ErrorResult(string[] errorMessages) => new() @@ -149,32 +151,17 @@ Json.DiffResult ErrorResult(string[] errorMessages) => ); // Build dictionaries keyed by path - Dictionary leftDict = []; - foreach ((RpfFile rpf, RpfFileEntry entry) in leftFiles) - { - leftDict[entry.Path] = (rpf, entry); - } + Dictionary leftDict = + leftFiles.ToDictionary(f => f.entry.Path, f => f); - Dictionary rightDict = []; - foreach ((RpfFile rpf, RpfFileEntry entry) in rightFiles) - { - rightDict[entry.Path] = (rpf, entry); - } + Dictionary rightDict = + rightFiles.ToDictionary(f => f.entry.Path, f => f); SizeFormat sizeFormat = options.Common.SizeFormat; // Find removed and modified/unchanged — entries in left that also appear in right // need byte comparison, so parallelize this - string[] commonPaths; - { - List paths = []; - foreach (string path in leftDict.Keys) - { - if (rightDict.ContainsKey(path)) - paths.Add(path); - } - commonPaths = [.. paths]; - } + string[] commonPaths = leftDict.Keys.Where(rightDict.ContainsKey).ToArray(); // Result per common path: null = unchanged, non-null = modified entry bool[] isModifiedArr = new bool[commonPaths.Length]; @@ -182,9 +169,10 @@ Json.DiffResult ErrorResult(string[] errorMessages) => _ = Parallel.For( 0, commonPaths.Length, - new ParallelOptions { MaxDegreeOfParallelism = options.Common.Threads }, + new ParallelOptions { MaxDegreeOfParallelism = options.Common.Threads, CancellationToken = cancellationToken }, i => { + cancellationToken.ThrowIfCancellationRequested(); string path = commonPaths[i]; (RpfFile leftRpfRef, RpfFileEntry leftEntry) = leftDict[path]; (RpfFile rightRpfRef, RpfFileEntry rightEntry) = rightDict[path]; @@ -253,42 +241,40 @@ Json.DiffResult ErrorResult(string[] errorMessages) => } // Find removed (left only) - foreach (KeyValuePair kvp in leftDict) - { - if (!rightDict.ContainsKey(kvp.Key)) - { - long size = kvp.Value.entry.GetFileSize(); - removed.Add( - new Json.DiffEntry + removed.AddRange( + leftDict + .Where(kvp => !rightDict.ContainsKey(kvp.Key)) + .Select(kvp => + { + long size = kvp.Value.entry.GetFileSize(); + return new Json.DiffEntry { Path = kvp.Key, Name = kvp.Value.entry.Name, Type = RpfService.GetFileType(kvp.Value.entry), Size = size, SizeFormatted = sizeFormat.ToFormattedString(size), - } - ); - } - } + }; + }) + ); // Find added (right only) - foreach (KeyValuePair kvp in rightDict) - { - if (!leftDict.ContainsKey(kvp.Key)) - { - long size = kvp.Value.entry.GetFileSize(); - added.Add( - new Json.DiffEntry + added.AddRange( + rightDict + .Where(kvp => !leftDict.ContainsKey(kvp.Key)) + .Select(kvp => + { + long size = kvp.Value.entry.GetFileSize(); + return new Json.DiffEntry { Path = kvp.Key, Name = kvp.Value.entry.Name, Type = RpfService.GetFileType(kvp.Value.entry), Size = size, SizeFormatted = sizeFormat.ToFormattedString(size), - } - ); - } - } + }; + }) + ); // Sort alphabetically added.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path)); @@ -374,6 +360,7 @@ Json.DiffResult ErrorResult(string[] errorMessages) => return errorMessages.Count > 0 ? 1 : 0; } + catch (OperationCanceledException) { throw; } catch (Exception ex) { return RpfService.ReportError( diff --git a/CodeWalker.Cli/ExportAudioHandler.cs b/CodeWalker.Cli/ExportAudioHandler.cs index 388008c2e..84feaeb6f 100644 --- a/CodeWalker.Cli/ExportAudioHandler.cs +++ b/CodeWalker.Cli/ExportAudioHandler.cs @@ -1,5 +1,6 @@ using System.CommandLine; using System.IO; +using System.Threading; using CodeWalker.GameFiles; @@ -9,7 +10,7 @@ internal static class ExportAudioHandler { private static readonly string[] DefaultFilters = ["*.awc"]; - public static Command CreateCommand() + public static Command CreateCommand(CancellationToken cancellationToken = default) { ExportCommandOptions exportOpts = new(); @@ -25,7 +26,7 @@ public static Command CreateCommand() { options = options with { Rpf = options.Rpf with { Filters = DefaultFilters } }; } - return ExportService.Execute(options, "wav", "Audio", ProcessFile); + return ExportService.Execute(options, "wav", "Audio", ProcessFile, cancellationToken); }); return command; @@ -57,6 +58,7 @@ bool noOverwrite int streamCount = 0; foreach (AwcStream stream in awc.Streams) { + // Hash 0 indicates a metadata-only stream with no playable audio data if (stream.Hash == 0) continue; @@ -69,8 +71,7 @@ bool noOverwrite continue; if (!dirCreated) { - if (!Directory.Exists(fileOutputDir)) - _ = Directory.CreateDirectory(fileOutputDir); + _ = Directory.CreateDirectory(fileOutputDir); dirCreated = true; } File.WriteAllBytes(midiPath, stream.MidiChunk.Data); @@ -84,8 +85,7 @@ bool noOverwrite continue; if (!dirCreated) { - if (!Directory.Exists(fileOutputDir)) - _ = Directory.CreateDirectory(fileOutputDir); + _ = Directory.CreateDirectory(fileOutputDir); dirCreated = true; } File.WriteAllBytes(wavPath, wav); diff --git a/CodeWalker.Cli/ExportHandler.cs b/CodeWalker.Cli/ExportHandler.cs index c9b3ef794..e9a9d7fc2 100644 --- a/CodeWalker.Cli/ExportHandler.cs +++ b/CodeWalker.Cli/ExportHandler.cs @@ -1,5 +1,6 @@ using System.CommandLine; using System.IO; +using System.Threading; namespace CodeWalker.Cli; @@ -58,17 +59,17 @@ public void AddTo(Command command) internal static class ExportHandler { - public static Command CreateCommand() + public static Command CreateCommand(CancellationToken cancellationToken = default) { Command command = new( "export", "Export game files to external formats (XML, DDS, WAV, text)" ) { - ExportXmlHandler.CreateCommand(), - ExportTexturesHandler.CreateCommand(), - ExportAudioHandler.CreateCommand(), - ExportTextHandler.CreateCommand(), + ExportXmlHandler.CreateCommand(cancellationToken), + ExportTexturesHandler.CreateCommand(cancellationToken), + ExportAudioHandler.CreateCommand(cancellationToken), + ExportTextHandler.CreateCommand(cancellationToken), }; command.Aliases.Add("e"); diff --git a/CodeWalker.Cli/ExportService.cs b/CodeWalker.Cli/ExportService.cs index 84fc8d6b3..1b9f6b7c2 100644 --- a/CodeWalker.Cli/ExportService.cs +++ b/CodeWalker.Cli/ExportService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using CodeWalker.Cli.Helpers; @@ -137,7 +138,8 @@ public static int Execute( ExportOptions options, string format, string summaryLabel, - ExportFileProcessor processor + ExportFileProcessor processor, + CancellationToken cancellationToken = default ) { Json.ExportResult ErrorResult(string[] errorMessages) => @@ -213,9 +215,10 @@ Json.ExportResult ErrorResult(string[] errorMessages) => _ = Parallel.For( 0, filesToExport.Count, - new ParallelOptions { MaxDegreeOfParallelism = options.Rpf.Threads }, + new ParallelOptions { MaxDegreeOfParallelism = options.Rpf.Threads, CancellationToken = cancellationToken }, i => { + cancellationToken.ThrowIfCancellationRequested(); (RpfFile sourceRpf, RpfFileEntry fileEntry) = filesToExport[i]; try { @@ -318,6 +321,7 @@ Json.ExportResult ErrorResult(string[] errorMessages) => return (agg.Errors > 0 || scanErrors.Count > 0) ? 1 : 0; } + catch (OperationCanceledException) { throw; } catch (Exception ex) { return RpfService.ReportError( diff --git a/CodeWalker.Cli/ExportTextHandler.cs b/CodeWalker.Cli/ExportTextHandler.cs index 0017c7947..604e3a589 100644 --- a/CodeWalker.Cli/ExportTextHandler.cs +++ b/CodeWalker.Cli/ExportTextHandler.cs @@ -1,6 +1,7 @@ using System.CommandLine; using System.IO; using System.Text; +using System.Threading; using CodeWalker.GameFiles; @@ -10,7 +11,7 @@ internal static class ExportTextHandler { private static readonly string[] DefaultFilters = ["*.gxt2"]; - public static Command CreateCommand() + public static Command CreateCommand(CancellationToken cancellationToken = default) { ExportCommandOptions exportOpts = new(); @@ -26,7 +27,7 @@ public static Command CreateCommand() { options = options with { Rpf = options.Rpf with { Filters = DefaultFilters } }; } - return ExportService.Execute(options, "txt", "Text", ProcessFile); + return ExportService.Execute(options, "txt", "Text", ProcessFile, cancellationToken); }); return command; diff --git a/CodeWalker.Cli/ExportTexturesHandler.cs b/CodeWalker.Cli/ExportTexturesHandler.cs index 1d12c3bf3..7f60bc8fc 100644 --- a/CodeWalker.Cli/ExportTexturesHandler.cs +++ b/CodeWalker.Cli/ExportTexturesHandler.cs @@ -1,5 +1,6 @@ using System.CommandLine; using System.IO; +using System.Threading; using CodeWalker.GameFiles; using CodeWalker.Utils; @@ -10,7 +11,7 @@ internal static class ExportTexturesHandler { private static readonly string[] DefaultFilters = ["*.ytd"]; - public static Command CreateCommand() + public static Command CreateCommand(CancellationToken cancellationToken = default) { ExportCommandOptions exportOpts = new(); @@ -26,7 +27,7 @@ public static Command CreateCommand() { options = options with { Rpf = options.Rpf with { Filters = DefaultFilters } }; } - return ExportService.Execute(options, "dds", "Texture", ProcessFile); + return ExportService.Execute(options, "dds", "Texture", ProcessFile, cancellationToken); }); return command; @@ -69,8 +70,7 @@ bool noOverwrite if (!dirCreated) { - if (!Directory.Exists(fileOutputDir)) - _ = Directory.CreateDirectory(fileOutputDir); + _ = Directory.CreateDirectory(fileOutputDir); dirCreated = true; } diff --git a/CodeWalker.Cli/ExportXmlHandler.cs b/CodeWalker.Cli/ExportXmlHandler.cs index 13b759a87..a332a8648 100644 --- a/CodeWalker.Cli/ExportXmlHandler.cs +++ b/CodeWalker.Cli/ExportXmlHandler.cs @@ -1,6 +1,7 @@ using System.CommandLine; using System.IO; using System.Text; +using System.Threading; using CodeWalker.GameFiles; @@ -8,7 +9,7 @@ namespace CodeWalker.Cli; internal static class ExportXmlHandler { - public static Command CreateCommand() + public static Command CreateCommand(CancellationToken cancellationToken = default) { ExportCommandOptions exportOpts = new(); @@ -19,7 +20,7 @@ public static Command CreateCommand() command.SetAction(parseResult => { ExportOptions options = exportOpts.Parse(parseResult); - return ExportService.Execute(options, "xml", "XML", ProcessFile); + return ExportService.Execute(options, "xml", "XML", ProcessFile, cancellationToken); }); return command; diff --git a/CodeWalker.Cli/ExtractHandler.cs b/CodeWalker.Cli/ExtractHandler.cs index 7fadf724e..36f5d1fcd 100644 --- a/CodeWalker.Cli/ExtractHandler.cs +++ b/CodeWalker.Cli/ExtractHandler.cs @@ -22,7 +22,7 @@ internal sealed record ExtractOptions internal static class ExtractHandler { - public static Command CreateCommand() + public static Command CreateCommand(CancellationToken cancellationToken = default) { RpfCommandOptions rpfOpts = new(); Option outputOption = new("--output", "-o") @@ -66,13 +66,13 @@ public static Command CreateCommand() NoOverwrite = parseResult.GetValue(noOverwriteOption), Progress = parseResult.GetValue(progressOption), }; - return Execute(options); + return Execute(options, cancellationToken); }); return command; } - public static int Execute(ExtractOptions options) + public static int Execute(ExtractOptions options, CancellationToken cancellationToken = default) { Json.ExtractResult ErrorResult(string[] errorMessages) => new() @@ -150,9 +150,10 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => _ = Parallel.For( 0, filesToExtract.Count, - new ParallelOptions { MaxDegreeOfParallelism = options.Rpf.Threads }, + new ParallelOptions { MaxDegreeOfParallelism = options.Rpf.Threads, CancellationToken = cancellationToken }, i => { + cancellationToken.ThrowIfCancellationRequested(); (RpfFile sourceRpf, RpfFileEntry fileEntry) = filesToExtract[i]; try { @@ -203,7 +204,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => } else { - if (!string.IsNullOrEmpty(fileDir) && !Directory.Exists(fileDir)) + if (!string.IsNullOrEmpty(fileDir)) { _ = Directory.CreateDirectory(fileDir); } @@ -274,7 +275,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => foreach ((Json.FileEntry? jsonEntry, string? errorMessage) in results) { - if (jsonEntry?.Status is "extracted" or "dry_run") + if (errorMessage == null && jsonEntry?.Status is "extracted" or "dry_run") extracted++; if (jsonEntry != null) @@ -320,6 +321,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => return (errors > 0 || scanErrors.Count > 0) ? 1 : 0; } + catch (OperationCanceledException) { throw; } catch (Exception ex) { return RpfService.ReportError( diff --git a/CodeWalker.Cli/Gen9Handler.cs b/CodeWalker.Cli/Gen9Handler.cs index 236a21efe..ce91fd1df 100644 --- a/CodeWalker.Cli/Gen9Handler.cs +++ b/CodeWalker.Cli/Gen9Handler.cs @@ -2,7 +2,9 @@ using System.Collections.Generic; using System.CommandLine; using System.IO; +using System.Linq; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using CodeWalker.Cli.Helpers; @@ -24,7 +26,7 @@ internal sealed record Gen9Options internal static class Gen9Handler { - public static Command CreateCommand() + public static Command CreateCommand(CancellationToken cancellationToken = default) { CommonCommandOptions commonOpts = new(); Option inputOption = new("--input", "-i") @@ -59,7 +61,7 @@ public static Command CreateCommand() Description = "Show progress bar", }; - Command command = new("gen9", "Convert files between standard and enhanced (Gen9) formats") + Command command = new("gen9", "Convert files to enhanced (Gen9) format") { inputOption, outputOption, @@ -83,13 +85,13 @@ public static Command CreateCommand() SkipUnconverted = parseResult.GetValue(skipUnconvertedOption), Progress = parseResult.GetValue(progressOption), }; - return Execute(options); + return Execute(options, cancellationToken); }); return command; } - public static int Execute(Gen9Options options) + public static int Execute(Gen9Options options, CancellationToken cancellationToken = default) { Json.Gen9Result ErrorResult(string[] errorMessages) => new() @@ -164,22 +166,11 @@ Json.Gen9Result ErrorResult(string[] errorMessages) => string[] allPaths = Directory.GetFileSystemEntries(inputFolder, "*", searchOption); - List filePaths = []; - List rpfPaths = []; - foreach (string p in allPaths) - { - if (!File.Exists(p)) - continue; - - if (Path.GetExtension(p).Equals(".rpf", StringComparison.OrdinalIgnoreCase)) - { - rpfPaths.Add(p); - } - else - { - filePaths.Add(p); - } - } + ILookup pathsByType = allPaths + .Where(File.Exists) + .ToLookup(p => Path.GetExtension(p).Equals(".rpf", StringComparison.OrdinalIgnoreCase)); + List rpfPaths = [.. pathsByType[true]]; + List filePaths = [.. pathsByType[false]]; int totalFileCount = filePaths.Count + rpfPaths.Count; @@ -213,9 +204,10 @@ Json.Gen9Result ErrorResult(string[] errorMessages) => _ = Parallel.For( 0, filePaths.Count, - new ParallelOptions { MaxDegreeOfParallelism = options.Common.Threads }, + new ParallelOptions { MaxDegreeOfParallelism = options.Common.Threads, CancellationToken = cancellationToken }, i => { + cancellationToken.ThrowIfCancellationRequested(); string path = filePaths[i]; string relPath = path[inputFolder.Length..]; string outPath = Path.Combine(options.OutputPath, relPath); @@ -360,6 +352,7 @@ out bool wasConverted // Process RPF files sequentially (unsafe to parallelize) foreach (string path in rpfPaths) { + cancellationToken.ThrowIfCancellationRequested(); string relPath = path[inputFolder.Length..]; string outPath = Path.Combine(options.OutputPath, relPath); @@ -460,6 +453,7 @@ ref errors RpfManager.IsGen9 = previousGen9; } } + catch (OperationCanceledException) { throw; } catch (Exception ex) { return RpfService.ReportError( @@ -487,11 +481,7 @@ ref int errors Console.Error.WriteLine($"{relPath} - Converting RPF contents..."); } - if (File.Exists(outputPath)) - { - File.Delete(outputPath); - } - File.Copy(inputPath, outputPath); + File.Copy(inputPath, outputPath, overwrite: true); RpfFile rpf = new(outputPath, relPath); rpf.ScanStructure( @@ -535,13 +525,10 @@ ref int errors bool changed = changedParents.Contains(currentRpf); - List resourceEntries = []; - foreach (RpfEntry entry in currentRpf.AllEntries) - { - if (entry is RpfResourceFileEntry rfe) - resourceEntries.Add(rfe); - } - resourceEntries.Sort((a, b) => a.FileOffset.CompareTo(b.FileOffset)); + List resourceEntries = currentRpf.AllEntries + .OfType() + .OrderBy(rfe => rfe.FileOffset) + .ToList(); foreach (RpfResourceFileEntry rfe in resourceEntries) { diff --git a/CodeWalker.Cli/HashHandler.cs b/CodeWalker.Cli/HashHandler.cs index 1216c0bdc..1155e002f 100644 --- a/CodeWalker.Cli/HashHandler.cs +++ b/CodeWalker.Cli/HashHandler.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.CommandLine; using System.Text.Json; +using System.Threading; using CodeWalker.GameFiles; @@ -19,7 +20,7 @@ internal sealed record HashOptions internal static class HashHandler { - public static Command CreateCommand() + public static Command CreateCommand(CancellationToken cancellationToken = default) { Option inputOption = new("--input", "-i") { @@ -55,13 +56,13 @@ public static Command CreateCommand() Encoding = parseResult.GetValue(encodingOption) ?? HashOptions.DefaultEncoding, Json = parseResult.GetValue(jsonOption), }; - return Execute(options); + return Execute(options, cancellationToken); }); return command; } - public static int Execute(HashOptions options) + public static int Execute(HashOptions options, CancellationToken cancellationToken = default) { static Json.HashResult ErrorResult(string[] errorMessages) => new() @@ -98,6 +99,7 @@ static Json.HashResult ErrorResult(string[] errorMessages) => foreach (string input in options.Inputs) { + cancellationToken.ThrowIfCancellationRequested(); JenkHash jenkHash = new(input, encoding); Json.HashEntry entry = new() @@ -135,6 +137,7 @@ static Json.HashResult ErrorResult(string[] errorMessages) => return 0; } + catch (OperationCanceledException) { throw; } catch (Exception ex) { return RpfService.ReportError(ex.Message, options.Json, ErrorResult([])); diff --git a/CodeWalker.Cli/InspectHandler.cs b/CodeWalker.Cli/InspectHandler.cs index 4055afe12..b880b268c 100644 --- a/CodeWalker.Cli/InspectHandler.cs +++ b/CodeWalker.Cli/InspectHandler.cs @@ -2,7 +2,9 @@ using System.Collections.Generic; using System.CommandLine; using System.IO; +using System.Linq; using System.Text.Json; +using System.Threading; using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; @@ -13,7 +15,7 @@ namespace CodeWalker.Cli; internal static class InspectHandler { - public static Command CreateCommand() + public static Command CreateCommand(CancellationToken cancellationToken = default) { RpfCommandOptions rpfOpts = new(); Argument pathArg = new("path") @@ -32,14 +34,16 @@ public static Command CreateCommand() command.Aliases.Add("i"); command.SetAction(parseResult => - Execute(rpfOpts.Parse(parseResult), parseResult.GetRequiredValue(pathArg)) + Execute(rpfOpts.Parse(parseResult), parseResult.GetRequiredValue(pathArg), cancellationToken) ); return command; } - public static int Execute(RpfOptions options, string filePath) + public static int Execute(RpfOptions options, string filePath, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + Json.InspectResult ErrorResult(string[] errorMessages) => new() { @@ -152,6 +156,7 @@ Json.InspectResult ErrorResult(string[] errorMessages) => return scanErrors.Count > 0 ? 1 : 0; } + catch (OperationCanceledException) { throw; } catch (Exception ex) { return RpfService.ReportError( @@ -165,25 +170,19 @@ Json.InspectResult ErrorResult(string[] errorMessages) => private static RpfFileEntry? FindEntry(RpfFile rpf, string normalizedPath, bool recursive) { - if (rpf.AllEntries != null) - { - foreach (RpfEntry entry in rpf.AllEntries) - { - if ( - entry is RpfFileEntry fileEntry - && entry.Path?.Replace('\\', '/').Equals(normalizedPath, StringComparison.OrdinalIgnoreCase) == true - ) - { - return fileEntry; - } - } - } + RpfFileEntry? found = rpf.AllEntries? + .OfType() + .FirstOrDefault(fe => + fe.Path?.Replace('\\', '/').Equals(normalizedPath, StringComparison.OrdinalIgnoreCase) == true); + + if (found != null) + return found; if (recursive && rpf.Children != null) { foreach (RpfFile child in rpf.Children) { - RpfFileEntry? found = FindEntry(child, normalizedPath, recursive); + found = FindEntry(child, normalizedPath, recursive); if (found != null) return found; } @@ -192,7 +191,7 @@ entry is RpfFileEntry fileEntry return null; } - private static object? GetDetails(RpfFileEntry entry, string ext, bool verbose) + private static Json.InspectDetailBase? GetDetails(RpfFileEntry entry, string ext, bool verbose) { try { @@ -227,23 +226,18 @@ entry is RpfFileEntry fileEntry return null; Texture[] textures = file.TextureDict.Textures.data_items; - List infos = []; - foreach (Texture tex in textures) - { - if (tex == null) - continue; - infos.Add( - new Json.TextureInfo - { - Name = tex.Name ?? "", - Width = tex.Width, - Height = tex.Height, - Format = tex.Format.ToString(), - MipLevels = tex.Levels, - Stride = tex.Stride, - } - ); - } + List infos = textures + .Where(tex => tex != null) + .Select(tex => new Json.TextureInfo + { + Name = tex.Name ?? "", + Width = tex.Width, + Height = tex.Height, + Format = tex.Format.ToString(), + MipLevels = tex.Levels, + Stride = tex.Stride, + }) + .ToList(); return new Json.YtdDetails { TextureCount = infos.Count, Textures = infos }; } @@ -264,35 +258,22 @@ entry is RpfFileEntry fileEntry return null; Drawable?[] drawables = file.DrawableDict.Drawables.data_items; - List infos = []; - foreach (Drawable? d in drawables) - { - if (d == null) - continue; - long verts = 0; - long tris = 0; - if (d.AllModels != null) + List infos = drawables + .Where(d => d != null) + .Select(d => { - foreach (DrawableModel? model in d.AllModels) - { - if (model?.Geometries == null) - continue; - foreach (DrawableGeometry? geom in model.Geometries) - { - verts += geom.VerticesCount; - tris += geom.TrianglesCount; - } - } - } - infos.Add( - new Json.DrawableInfo + DrawableGeometry[] geoms = (d!.AllModels ?? []) + .Where(m => m?.Geometries != null) + .SelectMany(m => m.Geometries) + .ToArray(); + return new Json.DrawableInfo { Name = d.Name ?? "", - TotalVertices = verts, - TotalTriangles = tris, - } - ); - } + TotalVertices = geoms.Sum(g => (long)g.VerticesCount), + TotalTriangles = geoms.Sum(g => (long)g.TrianglesCount), + }; + }) + .ToList(); return new Json.YddDetails { DrawableCount = infos.Count, Drawables = infos }; } @@ -354,35 +335,20 @@ entry is RpfFileEntry fileEntry if (file?.AllArchetypes == null) return null; - int baseCount = 0; - int timeCount = 0; - int mloCount = 0; - List mloDetails = []; - - foreach (Archetype? arch in file.AllArchetypes) - { - if (arch is MloArchetype mlo) + List mloDetails = file.AllArchetypes + .OfType() + .Select(mlo => new Json.MloInfo { - mloCount++; - mloDetails.Add( - new Json.MloInfo - { - Name = mlo.Hash.ToString(), - EntityCount = mlo.entities?.Length ?? 0, - RoomCount = mlo.rooms?.Length ?? 0, - PortalCount = mlo.portals?.Length ?? 0, - } - ); - } - else if (arch is TimeArchetype) - { - timeCount++; - } - else - { - baseCount++; - } - } + Name = mlo.Hash.ToString(), + EntityCount = mlo.entities?.Length ?? 0, + RoomCount = mlo.rooms?.Length ?? 0, + PortalCount = mlo.portals?.Length ?? 0, + }) + .ToList(); + + int mloCount = mloDetails.Count; + int timeCount = file.AllArchetypes.OfType().Count(); + int baseCount = file.AllArchetypes.Length - mloCount - timeCount; return new Json.YtypDetails { @@ -419,23 +385,20 @@ entry is RpfFileEntry fileEntry if (file?.Streams == null) return null; - List infos = []; - foreach (AwcStream? stream in file.Streams) - { - if (stream?.StreamInfo == null) - continue; - - AwcFormatChunk? fmt = stream.FormatChunk; - infos.Add( - new Json.AwcStreamInfo + List infos = file.Streams + .Where(s => s?.StreamInfo != null) + .Select(s => + { + AwcFormatChunk? fmt = s.FormatChunk; + return new Json.AwcStreamInfo { - Id = stream.StreamInfo.Id, + Id = s.StreamInfo.Id, SamplesPerSecond = fmt?.SamplesPerSecond ?? 0, Codec = fmt?.Codec.ToString() ?? "unknown", Samples = fmt?.Samples ?? 0, - } - ); - } + }; + }) + .ToList(); return new Json.AwcDetails { StreamCount = infos.Count, Streams = infos }; } @@ -446,17 +409,16 @@ entry is RpfFileEntry fileEntry if (file?.TextEntries == null) return null; - List infos = []; - int limit = Math.Min(file.TextEntries.Length, 50); - for (int i = 0; i < limit; i++) - { - Gxt2Entry e = file.TextEntries[i]; - string text = e.Text ?? ""; - if (text.Length > 100) - text = text[..100] + "..."; - - infos.Add(new Json.Gxt2EntryInfo { Hash = $"0x{e.Hash:X8}", Text = text }); - } + List infos = file.TextEntries + .Take(50) + .Select(e => + { + string text = e.Text ?? ""; + if (text.Length > 100) + text = text[..100] + "..."; + return new Json.Gxt2EntryInfo { Hash = $"0x{e.Hash:X8}", Text = text }; + }) + .ToList(); return new Json.Gxt2Details { EntryCount = file.TextEntries.Length, Entries = infos }; } @@ -476,30 +438,19 @@ private static void AddLod(List lods, string level, DrawableModel[ if (models == null || models.Length == 0) return; - int geomCount = 0; - long totalVerts = 0; - long totalTris = 0; - - foreach (DrawableModel model in models) - { - if (model?.Geometries == null) - continue; - geomCount += model.Geometries.Length; - foreach (DrawableGeometry? geom in model.Geometries) - { - totalVerts += geom.VerticesCount; - totalTris += geom.TrianglesCount; - } - } + DrawableGeometry[] allGeoms = models + .Where(m => m?.Geometries != null) + .SelectMany(m => m.Geometries) + .ToArray(); lods.Add( new Json.LodInfo { Level = level, ModelCount = models.Length, - GeometryCount = geomCount, - TotalVertices = totalVerts, - TotalTriangles = totalTris, + GeometryCount = allGeoms.Length, + TotalVertices = allGeoms.Sum(g => (long)g.VerticesCount), + TotalTriangles = allGeoms.Sum(g => (long)g.TrianglesCount), } ); } diff --git a/CodeWalker.Cli/Json/InspectResult.cs b/CodeWalker.Cli/Json/InspectResult.cs index d5e4a9972..6e337030f 100644 --- a/CodeWalker.Cli/Json/InspectResult.cs +++ b/CodeWalker.Cli/Json/InspectResult.cs @@ -56,9 +56,21 @@ internal sealed record InspectResult : BaseResult [JsonPropertyName("details")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public object? Details { get; init; } + public InspectDetailBase? Details { get; init; } } +[JsonPolymorphic] +[JsonDerivedType(typeof(YtdDetails))] +[JsonDerivedType(typeof(YdrDetails))] +[JsonDerivedType(typeof(YddDetails))] +[JsonDerivedType(typeof(YftDetails))] +[JsonDerivedType(typeof(YmapDetails))] +[JsonDerivedType(typeof(YtypDetails))] +[JsonDerivedType(typeof(YbnDetails))] +[JsonDerivedType(typeof(AwcDetails))] +[JsonDerivedType(typeof(Gxt2Details))] +internal abstract record InspectDetailBase; + [ExcludeFromCodeCoverage] internal sealed record TextureInfo { @@ -82,7 +94,7 @@ internal sealed record TextureInfo } [ExcludeFromCodeCoverage] -internal sealed record YtdDetails +internal sealed record YtdDetails : InspectDetailBase { [JsonPropertyName("textureCount")] public required int TextureCount { get; init; } @@ -111,7 +123,7 @@ internal sealed record LodInfo } [ExcludeFromCodeCoverage] -internal sealed record YdrDetails +internal sealed record YdrDetails : InspectDetailBase { [JsonPropertyName("lods")] public required IReadOnlyList Lods { get; init; } @@ -131,7 +143,7 @@ internal sealed record DrawableInfo } [ExcludeFromCodeCoverage] -internal sealed record YddDetails +internal sealed record YddDetails : InspectDetailBase { [JsonPropertyName("drawableCount")] public required int DrawableCount { get; init; } @@ -141,7 +153,7 @@ internal sealed record YddDetails } [ExcludeFromCodeCoverage] -internal sealed record YftDetails +internal sealed record YftDetails : InspectDetailBase { [JsonPropertyName("lods")] public required IReadOnlyList Lods { get; init; } @@ -151,7 +163,7 @@ internal sealed record YftDetails } [ExcludeFromCodeCoverage] -internal sealed record YmapDetails +internal sealed record YmapDetails : InspectDetailBase { [JsonPropertyName("entityCount")] public required int EntityCount { get; init; } @@ -180,7 +192,7 @@ internal sealed record YmapDetails } [ExcludeFromCodeCoverage] -internal sealed record YtypDetails +internal sealed record YtypDetails : InspectDetailBase { [JsonPropertyName("archetypeCount")] public required int ArchetypeCount { get; init; } @@ -216,7 +228,7 @@ internal sealed record MloInfo } [ExcludeFromCodeCoverage] -internal sealed record YbnDetails +internal sealed record YbnDetails : InspectDetailBase { [JsonPropertyName("boundsType")] public required string BoundsType { get; init; } @@ -243,7 +255,7 @@ internal sealed record AwcStreamInfo } [ExcludeFromCodeCoverage] -internal sealed record AwcDetails +internal sealed record AwcDetails : InspectDetailBase { [JsonPropertyName("streamCount")] public required int StreamCount { get; init; } @@ -263,7 +275,7 @@ internal sealed record Gxt2EntryInfo } [ExcludeFromCodeCoverage] -internal sealed record Gxt2Details +internal sealed record Gxt2Details : InspectDetailBase { [JsonPropertyName("entryCount")] public required int EntryCount { get; init; } diff --git a/CodeWalker.Cli/ListHandler.cs b/CodeWalker.Cli/ListHandler.cs index 272342602..396d1df0e 100644 --- a/CodeWalker.Cli/ListHandler.cs +++ b/CodeWalker.Cli/ListHandler.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.IO; using System.Text.Json; +using System.Threading; using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; @@ -11,7 +12,7 @@ namespace CodeWalker.Cli; internal static class ListHandler { - public static Command CreateCommand() + public static Command CreateCommand(CancellationToken cancellationToken = default) { RpfCommandOptions rpfOpts = new(); @@ -19,12 +20,12 @@ public static Command CreateCommand() rpfOpts.AddTo(command); command.Aliases.Add("l"); - command.SetAction(parseResult => Execute(rpfOpts.Parse(parseResult))); + command.SetAction(parseResult => Execute(rpfOpts.Parse(parseResult), cancellationToken)); return command; } - public static int Execute(RpfOptions options) + public static int Execute(RpfOptions options, CancellationToken cancellationToken = default) { Json.ListResult ErrorResult(string[] errorMessages) => new() @@ -79,6 +80,7 @@ Json.ListResult ErrorResult(string[] errorMessages) => foreach ((RpfFile _, RpfFileEntry fileEntry) in entries) { + cancellationToken.ThrowIfCancellationRequested(); long size = fileEntry.GetFileSize(); totalSize += size; string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); @@ -136,6 +138,7 @@ Json.ListResult ErrorResult(string[] errorMessages) => return scanErrors.Count > 0 ? 1 : 0; } + catch (OperationCanceledException) { throw; } catch (Exception ex) { return RpfService.ReportError( diff --git a/CodeWalker.Cli/PackHandler.cs b/CodeWalker.Cli/PackHandler.cs index 4ea8d2770..2fbf82e97 100644 --- a/CodeWalker.Cli/PackHandler.cs +++ b/CodeWalker.Cli/PackHandler.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.IO; using System.Text.Json; +using System.Threading; using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; @@ -21,7 +22,7 @@ internal sealed record PackOptions internal static class PackHandler { - public static Command CreateCommand() + public static Command CreateCommand(CancellationToken cancellationToken = default) { CommonCommandOptions commonOpts = new(); Option inputOption = new("--input", "-i") @@ -73,13 +74,13 @@ public static Command CreateCommand() Force = parseResult.GetValue(forceOption), Progress = parseResult.GetValue(progressOption), }; - return Execute(options); + return Execute(options, cancellationToken); }); return command; } - public static int Execute(PackOptions options) + public static int Execute(PackOptions options, CancellationToken cancellationToken = default) { Json.PackResult ErrorResult(string[] errorMessages) => new() @@ -184,7 +185,8 @@ Json.PackResult ErrorResult(string[] errorMessages) => ref totalFiles, ref totalDirs, ref totalSize, - ref errors + ref errors, + cancellationToken ); } @@ -225,6 +227,7 @@ ref errors return errors > 0 ? 1 : 0; } + catch (OperationCanceledException) { throw; } catch (Exception ex) { return RpfService.ReportError( @@ -249,12 +252,14 @@ private static void AddDirectoryContents( ref int totalFiles, ref int totalDirs, ref long totalSize, - ref int errors + ref int errors, + CancellationToken cancellationToken ) { // Add subdirectories first foreach (string subDirPath in Directory.GetDirectories(fsDir)) { + cancellationToken.ThrowIfCancellationRequested(); string dirName = Path.GetFileName(subDirPath); try { @@ -275,7 +280,8 @@ ref int errors ref totalFiles, ref totalDirs, ref totalSize, - ref errors + ref errors, + cancellationToken ); } catch (Exception ex) @@ -293,6 +299,7 @@ ref errors // Add files foreach (string filePath in Directory.GetFiles(fsDir)) { + cancellationToken.ThrowIfCancellationRequested(); string fileName = Path.GetFileName(filePath); try { diff --git a/CodeWalker.Cli/Program.cs b/CodeWalker.Cli/Program.cs index af0fdd0dc..2061886a0 100644 --- a/CodeWalker.Cli/Program.cs +++ b/CodeWalker.Cli/Program.cs @@ -1,23 +1,39 @@ #if !TESTING +using System; using System.CommandLine; +using System.Threading; using CodeWalker.Cli; +using CancellationTokenSource cts = new(); +Console.CancelKeyPress += (_, e) => +{ + e.Cancel = true; + cts.Cancel(); +}; + RootCommand rootCommand = new(description: "CodeWalker CLI - RPF Archive Tool") { - ExtractHandler.CreateCommand(), - ListHandler.CreateCommand(), - HashHandler.CreateCommand(), - TreeHandler.CreateCommand(), - Gen9Handler.CreateCommand(), - PackHandler.CreateCommand(), - DiffHandler.CreateCommand(), - ExportHandler.CreateCommand(), - StatHandler.CreateCommand(), - SearchHandler.CreateCommand(), - ValidateHandler.CreateCommand(), - InspectHandler.CreateCommand(), + ExtractHandler.CreateCommand(cts.Token), + ListHandler.CreateCommand(cts.Token), + HashHandler.CreateCommand(cts.Token), + TreeHandler.CreateCommand(cts.Token), + Gen9Handler.CreateCommand(cts.Token), + PackHandler.CreateCommand(cts.Token), + DiffHandler.CreateCommand(cts.Token), + ExportHandler.CreateCommand(cts.Token), + StatHandler.CreateCommand(cts.Token), + SearchHandler.CreateCommand(cts.Token), + ValidateHandler.CreateCommand(cts.Token), + InspectHandler.CreateCommand(cts.Token), }; -return rootCommand.Parse(args).Invoke(); +try +{ + return rootCommand.Parse(args).Invoke(); +} +catch (OperationCanceledException) +{ + return 130; +} #endif diff --git a/CodeWalker.Cli/RpfService.cs b/CodeWalker.Cli/RpfService.cs index 072351704..58f87560c 100644 --- a/CodeWalker.Cli/RpfService.cs +++ b/CodeWalker.Cli/RpfService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; @@ -99,19 +100,13 @@ private static void CollectFilesRecursive( { if (rpf.AllEntries != null) { - foreach (RpfEntry entry in rpf.AllEntries) - { - if (entry is RpfFileEntry fileEntry) - { - if (entry.NameLower.EndsWith(".rpf", StringComparison.Ordinal)) - continue; - - if (!Filter.Matches(entry.Path, filters)) - continue; - - files.Add((rpf, fileEntry)); - } - } + files.AddRange( + rpf.AllEntries + .OfType() + .Where(fe => !fe.NameLower.EndsWith(".rpf", StringComparison.Ordinal) + && Filter.Matches(fe.Path, filters)) + .Select(fe => (rpf, fe)) + ); } if (recursive && rpf.Children != null) diff --git a/CodeWalker.Cli/SearchHandler.cs b/CodeWalker.Cli/SearchHandler.cs index 5b06b5b4c..f0238cd97 100644 --- a/CodeWalker.Cli/SearchHandler.cs +++ b/CodeWalker.Cli/SearchHandler.cs @@ -2,7 +2,9 @@ using System.Collections.Generic; using System.CommandLine; using System.IO; +using System.Linq; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using CodeWalker.Cli.Helpers; @@ -12,7 +14,7 @@ namespace CodeWalker.Cli; internal static class SearchHandler { - public static Command CreateCommand() + public static Command CreateCommand(CancellationToken cancellationToken = default) { RpfCommandOptions rpfOpts = new(); Argument patternArg = new("pattern") @@ -28,13 +30,13 @@ public static Command CreateCommand() command.Aliases.Add("s"); command.SetAction(parseResult => - Execute(rpfOpts.Parse(parseResult), parseResult.GetRequiredValue(patternArg)) + Execute(rpfOpts.Parse(parseResult), parseResult.GetRequiredValue(patternArg), cancellationToken) ); return command; } - public static int Execute(RpfOptions options, string pattern) + public static int Execute(RpfOptions options, string pattern, CancellationToken cancellationToken = default) { Json.SearchResult ErrorResult(string[] errorMessages) => new() @@ -135,9 +137,10 @@ out uint hash _ = Parallel.For( 0, allEntries.Count, - new ParallelOptions { MaxDegreeOfParallelism = options.Threads }, + new ParallelOptions { MaxDegreeOfParallelism = options.Threads, CancellationToken = cancellationToken }, i => { + cancellationToken.ThrowIfCancellationRequested(); RpfEntry entry = allEntries[i]; if (!matcher(entry)) return; @@ -167,12 +170,7 @@ out uint hash ); // Collect non-null results - List matches = []; - foreach (Json.SearchMatch? match in results) - { - if (match != null) - matches.Add(match); - } + List matches = results.OfType().ToList(); Json.SearchResult result = new() { @@ -216,6 +214,7 @@ out uint hash return scanErrors.Count > 0 ? 1 : 0; } + catch (OperationCanceledException) { throw; } catch (Exception ex) { return RpfService.ReportError( diff --git a/CodeWalker.Cli/StatHandler.cs b/CodeWalker.Cli/StatHandler.cs index 870e28534..127362994 100644 --- a/CodeWalker.Cli/StatHandler.cs +++ b/CodeWalker.Cli/StatHandler.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Text.Json; +using System.Threading; using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; @@ -12,7 +13,7 @@ namespace CodeWalker.Cli; internal static class StatHandler { - public static Command CreateCommand() + public static Command CreateCommand(CancellationToken cancellationToken = default) { RpfCommandOptions rpfOpts = new(); @@ -20,12 +21,12 @@ public static Command CreateCommand() rpfOpts.AddTo(command); command.Aliases.Add("S"); - command.SetAction(parseResult => Execute(rpfOpts.Parse(parseResult))); + command.SetAction(parseResult => Execute(rpfOpts.Parse(parseResult), cancellationToken)); return command; } - public static int Execute(RpfOptions options) + public static int Execute(RpfOptions options, CancellationToken cancellationToken = default) { Json.StatResult ErrorResult(string[] errorMessages) => new() @@ -86,6 +87,7 @@ Json.StatResult ErrorResult(string[] errorMessages) => foreach ((RpfFile _, RpfFileEntry fileEntry) in entries) { + cancellationToken.ThrowIfCancellationRequested(); long size = fileEntry.GetFileSize(); totalSize += size; string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); @@ -191,6 +193,7 @@ .. extStats return scanErrors.Count > 0 ? 1 : 0; } + catch (OperationCanceledException) { throw; } catch (Exception ex) { return RpfService.ReportError( diff --git a/CodeWalker.Cli/TreeHandler.cs b/CodeWalker.Cli/TreeHandler.cs index b51208e5f..486a1ce3b 100644 --- a/CodeWalker.Cli/TreeHandler.cs +++ b/CodeWalker.Cli/TreeHandler.cs @@ -2,7 +2,9 @@ using System.Collections.Generic; using System.CommandLine; using System.IO; +using System.Linq; using System.Text.Json; +using System.Threading; using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; @@ -17,7 +19,7 @@ internal sealed record TreeOptions internal static class TreeHandler { - public static Command CreateCommand() + public static Command CreateCommand(CancellationToken cancellationToken = default) { RpfCommandOptions rpfOpts = new(); Option depthOption = new("--depth", "-d") @@ -46,13 +48,13 @@ public static Command CreateCommand() Rpf = rpfOpts.Parse(parseResult), Depth = parseResult.GetValue(depthOption), }; - return Execute(options); + return Execute(options, cancellationToken); }); return command; } - public static int Execute(TreeOptions options) + public static int Execute(TreeOptions options, CancellationToken cancellationToken = default) { Json.TreeResult ErrorResult(string[] errorMessages) => new() @@ -97,7 +99,8 @@ Json.TreeResult ErrorResult(string[] errorMessages) => options, 0, ref totalFiles, - ref totalDirs + ref totalDirs, + cancellationToken ); Json.TreeResult result = new() @@ -117,7 +120,7 @@ ref totalDirs else { Console.WriteLine(Path.GetFileName(options.Rpf.RpfPath)); - PrintTree(rpf.Root, rpf, options, "", 0, ref totalFiles, ref totalDirs); + PrintTree(rpf.Root, rpf, options, "", 0, ref totalFiles, ref totalDirs, cancellationToken); Console.Error.WriteLine(); Console.Error.WriteLine($"{totalDirs} directories, {totalFiles} files"); @@ -125,6 +128,7 @@ ref totalDirs return scanErrors.Count > 0 ? 1 : 0; } + catch (OperationCanceledException) { throw; } catch (Exception ex) { return RpfService.ReportError( @@ -143,9 +147,11 @@ private static void PrintTree( string prefix, int depth, ref int totalFiles, - ref int totalDirs + ref int totalDirs, + CancellationToken cancellationToken ) { + cancellationToken.ThrowIfCancellationRequested(); if (options.Depth >= 0 && depth > options.Depth) return; @@ -178,7 +184,8 @@ ref int totalDirs childPrefix, depth + 1, ref totalFiles, - ref totalDirs + ref totalDirs, + cancellationToken ); } } @@ -213,9 +220,11 @@ private static Json.TreeNode BuildTreeNode( TreeOptions options, int depth, ref int totalFiles, - ref int totalDirs + ref int totalDirs, + CancellationToken cancellationToken ) { + cancellationToken.ThrowIfCancellationRequested(); List children = []; if (options.Depth < 0 || depth < options.Depth) @@ -237,7 +246,8 @@ ref int totalDirs options, depth + 1, ref totalFiles, - ref totalDirs + ref totalDirs, + cancellationToken ) ); } @@ -299,20 +309,17 @@ ref totalDirs } // Add nested RPFs as directories if recursive - if (options.Rpf.Recursive && dir.Files != null) + if (options.Rpf.Recursive && dir.Files != null && rpf.Children != null) { foreach (RpfFileEntry fileEntry in dir.Files) { - if (fileEntry.NameLower.EndsWith(".rpf", StringComparison.Ordinal) && rpf.Children != null) + if (!fileEntry.NameLower.EndsWith(".rpf", StringComparison.Ordinal)) + continue; + + RpfFile? child = rpf.Children.FirstOrDefault(c => c.Name == fileEntry.Name && c.Root != null); + if (child != null) { - foreach (RpfFile child in rpf.Children) - { - if (child.Name == fileEntry.Name && child.Root != null) - { - items.Add((fileEntry.Name, true, child.Root, child)); - break; - } - } + items.Add((fileEntry.Name, true, child.Root, child)); } } } @@ -320,16 +327,12 @@ ref totalDirs // Add files (non-RPF, matching filters) if (dir.Files != null) { - foreach (RpfFileEntry fileEntry in dir.Files) - { - if (fileEntry.NameLower.EndsWith(".rpf", StringComparison.Ordinal)) - continue; - - if (!Filter.Matches(fileEntry.Path, options.Rpf.Filters)) - continue; - - items.Add((fileEntry.Name, false, fileEntry, null)); - } + items.AddRange( + dir.Files + .Where(fe => !fe.NameLower.EndsWith(".rpf", StringComparison.Ordinal) + && Filter.Matches(fe.Path, options.Rpf.Filters)) + .Select(fe => (fe.Name, false, (RpfEntry)fe, (RpfFile?)null)) + ); } return items; diff --git a/CodeWalker.Cli/ValidateHandler.cs b/CodeWalker.Cli/ValidateHandler.cs index bb69a8130..80ca7ae06 100644 --- a/CodeWalker.Cli/ValidateHandler.cs +++ b/CodeWalker.Cli/ValidateHandler.cs @@ -2,7 +2,9 @@ using System.Collections.Generic; using System.CommandLine; using System.IO; +using System.Linq; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using CodeWalker.Cli.Helpers; @@ -18,7 +20,7 @@ internal sealed record ValidateOptions internal static class ValidateHandler { - public static Command CreateCommand() + public static Command CreateCommand(CancellationToken cancellationToken = default) { RpfCommandOptions rpfOpts = new(); Option progressOption = new("--progress", "-P") @@ -40,13 +42,13 @@ public static Command CreateCommand() Rpf = rpfOpts.Parse(parseResult), Progress = parseResult.GetValue(progressOption), }; - return Execute(options); + return Execute(options, cancellationToken); }); return command; } - public static int Execute(ValidateOptions options) + public static int Execute(ValidateOptions options, CancellationToken cancellationToken = default) { Json.ValidateResult ErrorResult(string[] errorMessages) => new() @@ -102,9 +104,10 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => _ = Parallel.For( 0, entries.Count, - new ParallelOptions { MaxDegreeOfParallelism = options.Rpf.Threads }, + new ParallelOptions { MaxDegreeOfParallelism = options.Rpf.Threads, CancellationToken = cancellationToken }, i => { + cancellationToken.ThrowIfCancellationRequested(); (_, RpfFileEntry fileEntry) = entries[i]; string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); @@ -164,39 +167,16 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => } // Aggregate results - int valid = 0; - int warnings = 0; - int errors = 0; - int skipped = 0; - List files = []; + List nonNull = results.OfType().ToList(); + int valid = nonNull.Count(e => e.Status == "valid"); + int warnings = nonNull.Count(e => e.Status == "warning"); + int errors = nonNull.Count(e => e.Status == "error"); + int skipped = nonNull.Count(e => e.Status == "skipped"); - foreach (Json.ValidateFileEntry? entry in results) - { - if (entry == null) - continue; - - switch (entry.Status) - { - case "valid": - valid++; - break; - case "warning": - warnings++; - break; - case "error": - errors++; - break; - case "skipped": - skipped++; - break; - } - - // In verbose mode or JSON, include all; otherwise only warnings/errors - if (options.Rpf.Json || options.Rpf.Verbose || entry.Status is "warning" or "error") - { - files.Add(entry); - } - } + // In verbose mode or JSON, include all; otherwise only warnings/errors + List files = (options.Rpf.Json || options.Rpf.Verbose) + ? nonNull + : nonNull.Where(e => e.Status is "warning" or "error").ToList(); Json.ValidateResult result = new() { @@ -227,6 +207,7 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => return (errors > 0 || scanErrors.Count > 0) ? 1 : 0; } + catch (OperationCanceledException) { throw; } catch (Exception ex) { return RpfService.ReportError( From b3153b86c21d65cae933d2893c8b028acc49c941 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:26:32 +0200 Subject: [PATCH 15/45] test(cli): cover the remaining handlers diff, export, extract, gen9, hash, inspect, list, pack, search, stat, tree and validate each get a test file. The archives are built in the test itself, so nothing depends on a GTA V installation being present. --- CodeWalker.Cli/DiffHandler.cs | 2 +- CodeWalker.Cli/InspectHandler.cs | 2 +- CodeWalker.Cli/Json/InspectResult.cs | 1 + CodeWalker.Cli/Program.cs | 2 - CodeWalker.Cli/SearchHandler.cs | 2 +- CodeWalker.Cli/Tests/DiffHandlerTests.cs | 216 +++++++++++ CodeWalker.Cli/Tests/ExportOptionsTests.cs | 82 ++++ CodeWalker.Cli/Tests/ExtractHandlerTests.cs | 153 ++++++++ CodeWalker.Cli/Tests/Gen9HandlerTests.cs | 209 ++++++++++ CodeWalker.Cli/Tests/HashHandlerTests.cs | 381 +++++++++++++++++++ CodeWalker.Cli/Tests/InspectHandlerTests.cs | 140 +++++++ CodeWalker.Cli/Tests/ListHandlerTests.cs | 128 +++++++ CodeWalker.Cli/Tests/PackHandlerTests.cs | 216 +++++++++++ CodeWalker.Cli/Tests/SearchHandlerTests.cs | 152 ++++++++ CodeWalker.Cli/Tests/StatHandlerTests.cs | 129 +++++++ CodeWalker.Cli/Tests/TreeHandlerTests.cs | 131 +++++++ CodeWalker.Cli/Tests/ValidateHandlerTests.cs | 133 +++++++ 17 files changed, 2074 insertions(+), 5 deletions(-) create mode 100644 CodeWalker.Cli/Tests/DiffHandlerTests.cs create mode 100644 CodeWalker.Cli/Tests/ExportOptionsTests.cs create mode 100644 CodeWalker.Cli/Tests/ExtractHandlerTests.cs create mode 100644 CodeWalker.Cli/Tests/Gen9HandlerTests.cs create mode 100644 CodeWalker.Cli/Tests/HashHandlerTests.cs create mode 100644 CodeWalker.Cli/Tests/InspectHandlerTests.cs create mode 100644 CodeWalker.Cli/Tests/ListHandlerTests.cs create mode 100644 CodeWalker.Cli/Tests/PackHandlerTests.cs create mode 100644 CodeWalker.Cli/Tests/SearchHandlerTests.cs create mode 100644 CodeWalker.Cli/Tests/StatHandlerTests.cs create mode 100644 CodeWalker.Cli/Tests/TreeHandlerTests.cs create mode 100644 CodeWalker.Cli/Tests/ValidateHandlerTests.cs diff --git a/CodeWalker.Cli/DiffHandler.cs b/CodeWalker.Cli/DiffHandler.cs index d139b2e7b..03c178f5e 100644 --- a/CodeWalker.Cli/DiffHandler.cs +++ b/CodeWalker.Cli/DiffHandler.cs @@ -372,7 +372,7 @@ Json.DiffResult ErrorResult(string[] errorMessages) => } } - private static bool ContentEquals(byte[]? a, byte[]? b) + internal static bool ContentEquals(byte[]? a, byte[]? b) { if (a == null && b == null) return true; diff --git a/CodeWalker.Cli/InspectHandler.cs b/CodeWalker.Cli/InspectHandler.cs index b880b268c..531ce7bdd 100644 --- a/CodeWalker.Cli/InspectHandler.cs +++ b/CodeWalker.Cli/InspectHandler.cs @@ -455,7 +455,7 @@ private static void AddLod(List lods, string level, DrawableModel[ ); } - private static string FormatVector3(Vector3 v) => + internal static string FormatVector3(Vector3 v) => $"{v.X:F2}, {v.Y:F2}, {v.Z:F2}"; private static void PrintTextResult(Json.InspectResult result, RpfOptions options) diff --git a/CodeWalker.Cli/Json/InspectResult.cs b/CodeWalker.Cli/Json/InspectResult.cs index 6e337030f..ad6f9617a 100644 --- a/CodeWalker.Cli/Json/InspectResult.cs +++ b/CodeWalker.Cli/Json/InspectResult.cs @@ -69,6 +69,7 @@ internal sealed record InspectResult : BaseResult [JsonDerivedType(typeof(YbnDetails))] [JsonDerivedType(typeof(AwcDetails))] [JsonDerivedType(typeof(Gxt2Details))] +[ExcludeFromCodeCoverage] internal abstract record InspectDetailBase; [ExcludeFromCodeCoverage] diff --git a/CodeWalker.Cli/Program.cs b/CodeWalker.Cli/Program.cs index 2061886a0..43e221181 100644 --- a/CodeWalker.Cli/Program.cs +++ b/CodeWalker.Cli/Program.cs @@ -1,4 +1,3 @@ -#if !TESTING using System; using System.CommandLine; using System.Threading; @@ -36,4 +35,3 @@ { return 130; } -#endif diff --git a/CodeWalker.Cli/SearchHandler.cs b/CodeWalker.Cli/SearchHandler.cs index f0238cd97..0b003e639 100644 --- a/CodeWalker.Cli/SearchHandler.cs +++ b/CodeWalker.Cli/SearchHandler.cs @@ -226,7 +226,7 @@ out uint hash } } - private static bool HasGlobChars(string s) => + internal static bool HasGlobChars(string s) => s.Contains('*', StringComparison.Ordinal) || s.Contains('?', StringComparison.Ordinal); diff --git a/CodeWalker.Cli/Tests/DiffHandlerTests.cs b/CodeWalker.Cli/Tests/DiffHandlerTests.cs new file mode 100644 index 000000000..e9b0a723d --- /dev/null +++ b/CodeWalker.Cli/Tests/DiffHandlerTests.cs @@ -0,0 +1,216 @@ +using System; +using System.IO; + +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +public sealed class DiffHandlerTests +{ + // ── ContentEquals ───────────────────────────────────────────────── + + [Fact] + public void ContentEquals_BothNull_ReturnsTrue() => + Assert.True(DiffHandler.ContentEquals(null, null)); + + [Fact] + public void ContentEquals_LeftNull_ReturnsFalse() => + Assert.False(DiffHandler.ContentEquals(null, [1, 2])); + + [Fact] + public void ContentEquals_RightNull_ReturnsFalse() => + Assert.False(DiffHandler.ContentEquals([1, 2], null)); + + [Fact] + public void ContentEquals_DifferentLengths_ReturnsFalse() => + Assert.False(DiffHandler.ContentEquals([1, 2], [1, 2, 3])); + + [Fact] + public void ContentEquals_SameContent_ReturnsTrue() => + Assert.True(DiffHandler.ContentEquals([1, 2, 3], [1, 2, 3])); + + [Fact] + public void ContentEquals_DifferentContent_ReturnsFalse() => + Assert.False(DiffHandler.ContentEquals([1, 2, 3], [1, 2, 4])); + + [Fact] + public void ContentEquals_BothEmpty_ReturnsTrue() => + Assert.True(DiffHandler.ContentEquals([], [])); + + [Fact] + public void ContentEquals_SingleByte_Same_ReturnsTrue() => + Assert.True(DiffHandler.ContentEquals([0xFF], [0xFF])); + + [Fact] + public void ContentEquals_SingleByte_Different_ReturnsFalse() => + Assert.False(DiffHandler.ContentEquals([0x00], [0xFF])); + + [Fact] + public void ContentEquals_DifferencesAtEnd_ReturnsFalse() => + Assert.False(DiffHandler.ContentEquals([1, 2, 3, 4, 5], [1, 2, 3, 4, 6])); + + [Fact] + public void ContentEquals_LargeIdenticalArrays_ReturnsTrue() + { + byte[] a = new byte[10_000]; + byte[] b = new byte[10_000]; + for (int i = 0; i < a.Length; i++) + { + a[i] = (byte)(i % 256); + b[i] = (byte)(i % 256); + } + Assert.True(DiffHandler.ContentEquals(a, b)); + } + + [Fact] + public void ContentEquals_LargeArrays_LastByteDiffers_ReturnsFalse() + { + byte[] a = new byte[10_000]; + byte[] b = new byte[10_000]; + for (int i = 0; i < a.Length; i++) + { + a[i] = (byte)(i % 256); + b[i] = (byte)(i % 256); + } + b[^1] = (byte)(a[^1] ^ 0xFF); + Assert.False(DiffHandler.ContentEquals(a, b)); + } +} + +[Collection("ConsoleOutput")] +public sealed class DiffHandlerExecuteTests +{ + private static DiffOptions MakeOptions(string leftPath, string rightPath, bool json) => + new() + { + LeftPath = leftPath, + RightPath = rightPath, + Common = new CommonOptions + { + ExePath = "/nonexistent", + Verbose = false, + Json = json, + SizeFormat = SizeFormat.IEC, + Threads = 1, + }, + Gen9 = false, + Recursive = false, + }; + + [Fact] + public void Execute_LeftMissing_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = DiffHandler.Execute(MakeOptions("/nonexistent/left.rpf", "/nonexistent/right.rpf", json: false)); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_LeftMissing_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = DiffHandler.Execute(MakeOptions("/nonexistent/left.rpf", "/nonexistent/right.rpf", json: true)); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("RPF file not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_RightMissing_WithExistingLeft_ReturnsOne() + { + string dir = Path.Combine(Path.GetTempPath(), "cw_diff_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(dir); + string leftRpf = Path.Combine(dir, "left.rpf"); + File.WriteAllBytes(leftRpf, []); + // Also need a valid exe dir + File.WriteAllBytes(Path.Combine(dir, "GTA5.exe"), []); + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + DiffOptions options = new() + { + LeftPath = leftRpf, + RightPath = "/nonexistent/right.rpf", + Common = new CommonOptions + { + ExePath = dir, + Verbose = false, + Json = false, + SizeFormat = SizeFormat.IEC, + Threads = 1, + }, + Gen9 = false, + Recursive = false, + }; + + int exitCode = DiffHandler.Execute(options); + + Assert.Equal(1, exitCode); + Assert.Contains("RPF file not found", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = DiffHandler.Execute(MakeOptions("/nonexistent/left.rpf", "/nonexistent/right.rpf", json: true)); + + string output = stdout.ToString(); + Assert.Contains("\"leftRpf\":", output); + Assert.Contains("\"rightRpf\":", output); + } + finally { Console.SetOut(origOut); } + } +} diff --git a/CodeWalker.Cli/Tests/ExportOptionsTests.cs b/CodeWalker.Cli/Tests/ExportOptionsTests.cs new file mode 100644 index 000000000..6ac8f35cb --- /dev/null +++ b/CodeWalker.Cli/Tests/ExportOptionsTests.cs @@ -0,0 +1,82 @@ +using System.CommandLine; + +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +public sealed class ExportOptionsTests +{ + [Fact] + public void Parse_MapsAllValues() + { + RootCommand root = []; + ExportCommandOptions opts = new(); + opts.AddTo(root); + ParseResult pr = root.Parse( + "--rpf /tmp/test.rpf --exe /tmp/testdir --output /tmp/out --dry-run --no-overwrite --progress --gen9 --recursive --verbose --json --si --threads 2" + ); + Assert.Empty(pr.Errors); + ExportOptions exportOpts = opts.Parse(pr); + Assert.EndsWith("out", exportOpts.OutputPath); + Assert.True(exportOpts.DryRun); + Assert.True(exportOpts.NoOverwrite); + Assert.True(exportOpts.Progress); + // Verify RPF sub-options are populated + Assert.True(exportOpts.Rpf.Gen9); + Assert.True(exportOpts.Rpf.Recursive); + Assert.True(exportOpts.Rpf.Verbose); + Assert.True(exportOpts.Rpf.Json); + Assert.Equal(SizeFormat.SI, exportOpts.Rpf.SizeFormat); + Assert.Equal(2, exportOpts.Rpf.Threads); + } + + [Fact] + public void Parse_Defaults() + { + RootCommand root = []; + ExportCommandOptions opts = new(); + opts.AddTo(root); + ParseResult pr = root.Parse("--rpf /tmp/test.rpf --exe /tmp/testdir"); + Assert.Empty(pr.Errors); + ExportOptions exportOpts = opts.Parse(pr); + Assert.False(exportOpts.DryRun); + Assert.False(exportOpts.NoOverwrite); + Assert.False(exportOpts.Progress); + Assert.NotEmpty(exportOpts.OutputPath); // defaults to cwd + } + + [Fact] + public void Parse_DryRunAlias() + { + RootCommand root = []; + ExportCommandOptions opts = new(); + opts.AddTo(root); + ParseResult pr = root.Parse("--rpf /tmp/test.rpf --exe /tmp/testdir -n"); + Assert.Empty(pr.Errors); + Assert.True(opts.Parse(pr).DryRun); + } + + [Fact] + public void Parse_ProgressAlias() + { + RootCommand root = []; + ExportCommandOptions opts = new(); + opts.AddTo(root); + ParseResult pr = root.Parse("--rpf /tmp/test.rpf --exe /tmp/testdir -P"); + Assert.Empty(pr.Errors); + Assert.True(opts.Parse(pr).Progress); + } + + [Fact] + public void Parse_OutputAlias() + { + RootCommand root = []; + ExportCommandOptions opts = new(); + opts.AddTo(root); + ParseResult pr = root.Parse("--rpf /tmp/test.rpf --exe /tmp/testdir -o /tmp/mydir"); + Assert.Empty(pr.Errors); + Assert.EndsWith("mydir", opts.Parse(pr).OutputPath); + } +} diff --git a/CodeWalker.Cli/Tests/ExtractHandlerTests.cs b/CodeWalker.Cli/Tests/ExtractHandlerTests.cs new file mode 100644 index 000000000..ffb4d7852 --- /dev/null +++ b/CodeWalker.Cli/Tests/ExtractHandlerTests.cs @@ -0,0 +1,153 @@ +using System; +using System.IO; + +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +[Collection("ConsoleOutput")] +public sealed class ExtractHandlerTests +{ + private static ExtractOptions MakeOptions(string rpfPath, bool json, bool dryRun = false) => + new() + { + Rpf = new RpfOptions + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, + }, + OutputPath = "/tmp/cw_extract_out", + DryRun = dryRun, + NoOverwrite = false, + Progress = false, + }; + + // ── Validation failures ──────────────────────────────────────────── + + [Fact] + public void Execute_MissingRpf_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = ExtractHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false)); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_MissingRpf_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = ExtractHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("RPF file not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = ExtractHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + + string output = stdout.ToString(); + Assert.Contains("\"rpfFile\":", output); + Assert.Contains("\"totalFiles\": 0", output); + Assert.Contains("\"extracted\": 0", output); + Assert.Contains("\"skipped\": 0", output); + Assert.Contains("\"errors\": 0", output); + Assert.Contains("\"dryRun\": false", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void Execute_MissingExe_WithExistingRpf_ReturnsOne() + { + string dir = Path.Combine(Path.GetTempPath(), "cw_ext_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(dir); + string rpf = Path.Combine(dir, "test.rpf"); + File.WriteAllBytes(rpf, []); + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + StringWriter stderr = new(); + Console.SetError(stderr); + + int exitCode = ExtractHandler.Execute(MakeOptions(rpf, json: false)); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void Execute_DryRun_Json_ErrorStillHasDryRunTrue() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = ExtractHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true, dryRun: true)); + + string output = stdout.ToString(); + Assert.Contains("\"dryRun\": true", output); + } + finally { Console.SetOut(origOut); } + } +} diff --git a/CodeWalker.Cli/Tests/Gen9HandlerTests.cs b/CodeWalker.Cli/Tests/Gen9HandlerTests.cs new file mode 100644 index 000000000..d940970f7 --- /dev/null +++ b/CodeWalker.Cli/Tests/Gen9HandlerTests.cs @@ -0,0 +1,209 @@ +using System; +using System.IO; + +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +[Collection("ConsoleOutput")] +public sealed class Gen9HandlerTests +{ + private static Gen9Options MakeOptions( + string inputPath, + string outputPath, + bool json, + string exePath = "/nonexistent" + ) => + new() + { + InputPath = inputPath, + OutputPath = outputPath, + Common = new CommonOptions + { + ExePath = exePath, + Verbose = false, + Json = json, + SizeFormat = SizeFormat.IEC, + Threads = 1, + }, + NoRecurse = false, + NoOverwrite = false, + SkipUnconverted = false, + Progress = false, + }; + + private static string CreateTempDir() + { + string dir = Path.Combine(Path.GetTempPath(), "cw_gen9_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(dir); + return dir; + } + + // ── Input folder missing ─────────────────────────────────────────── + + [Fact] + public void Execute_InputFolderMissing_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = Gen9Handler.Execute(MakeOptions("/nonexistent/input", "/tmp/out", json: false)); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + Assert.Contains("Input folder not found", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_InputFolderMissing_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = Gen9Handler.Execute(MakeOptions("/nonexistent/input", "/tmp/out", json: true)); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("Input folder not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + // ── Input equals output ──────────────────────────────────────────── + + [Fact] + public void Execute_InputEqualsOutput_ReturnsOne() + { + string dir = CreateTempDir(); + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = Gen9Handler.Execute(MakeOptions(dir, dir, json: false)); + + Assert.Equal(1, exitCode); + Assert.Contains("must be different", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void Execute_InputEqualsOutput_Json_ReturnsErrorJson() + { + string dir = CreateTempDir(); + try + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + int exitCode = Gen9Handler.Execute(MakeOptions(dir, dir, json: true)); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("must be different", output); + } + finally { Console.SetOut(origOut); } + } + finally { Directory.Delete(dir, true); } + } + + // ── Missing exe ──────────────────────────────────────────────────── + + [Fact] + public void Execute_MissingExe_ReturnsOne() + { + string inputDir = CreateTempDir(); + string outputDir = inputDir + "_out"; + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = Gen9Handler.Execute(MakeOptions(inputDir, outputDir, json: false)); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally + { + Directory.Delete(inputDir, true); + if (Directory.Exists(outputDir)) + Directory.Delete(outputDir, true); + } + } + + // ── JSON error structure ─────────────────────────────────────────── + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = Gen9Handler.Execute(MakeOptions("/nonexistent/input", "/tmp/out", json: true)); + + string output = stdout.ToString(); + Assert.Contains("\"inputFolder\":", output); + Assert.Contains("\"outputFolder\":", output); + Assert.Contains("\"totalFiles\": 0", output); + Assert.Contains("\"converted\": 0", output); + Assert.Contains("\"skipped\": 0", output); + Assert.Contains("\"copied\": 0", output); + Assert.Contains("\"errors\": 0", output); + } + finally { Console.SetOut(origOut); } + } +} diff --git a/CodeWalker.Cli/Tests/HashHandlerTests.cs b/CodeWalker.Cli/Tests/HashHandlerTests.cs new file mode 100644 index 000000000..acf9f7c05 --- /dev/null +++ b/CodeWalker.Cli/Tests/HashHandlerTests.cs @@ -0,0 +1,381 @@ +using System; +using System.IO; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +[Collection("ConsoleOutput")] +public sealed class HashHandlerTests +{ + // ── Encoding validation ─────────────────────────────────────────── + + [Fact] + public void Execute_UnknownEncoding_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + StringWriter stderr = new(); + Console.SetError(stderr); + + int exitCode = HashHandler.Execute(new HashOptions + { + Inputs = ["test"], + Encoding = "unknown-codec", + Json = false, + }); + + Assert.Equal(1, exitCode); + Assert.Contains("Unknown encoding", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_UnknownEncoding_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + int exitCode = HashHandler.Execute(new HashOptions + { + Inputs = ["test"], + Encoding = "bad", + Json = true, + }); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("Unknown encoding", output); + } + finally { Console.SetOut(origOut); } + } + + // ── Successful hashing ──────────────────────────────────────────── + + [Fact] + public void Execute_Utf8Encoding_ReturnsZero() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + int exitCode = HashHandler.Execute(new HashOptions + { + Inputs = ["test"], + Encoding = "utf8", + Json = false, + }); + + Assert.Equal(0, exitCode); + string output = stdout.ToString(); + Assert.Contains("Input: test", output); + Assert.Contains("Hash (uint):", output); + Assert.Contains("Hash (hex):", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void Execute_Utf8WithDash_ReturnsZero() + { + TextWriter origOut = Console.Out; + try + { + Console.SetOut(new StringWriter()); + + int exitCode = HashHandler.Execute(new HashOptions + { + Inputs = ["test"], + Encoding = "utf-8", + Json = false, + }); + + Assert.Equal(0, exitCode); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void Execute_AsciiEncoding_ReturnsZero() + { + TextWriter origOut = Console.Out; + try + { + Console.SetOut(new StringWriter()); + + int exitCode = HashHandler.Execute(new HashOptions + { + Inputs = ["hello"], + Encoding = "ascii", + Json = false, + }); + + Assert.Equal(0, exitCode); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void Execute_EncodingIsCaseInsensitive() + { + TextWriter origOut = Console.Out; + try + { + Console.SetOut(new StringWriter()); + + int exitCode = HashHandler.Execute(new HashOptions + { + Inputs = ["test"], + Encoding = "ASCII", + Json = false, + }); + + Assert.Equal(0, exitCode); + } + finally { Console.SetOut(origOut); } + } + + // ── Multiple inputs ─────────────────────────────────────────────── + + [Fact] + public void Execute_MultipleInputs_HashesAll() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + int exitCode = HashHandler.Execute(new HashOptions + { + Inputs = ["alpha", "bravo", "charlie"], + Encoding = "utf8", + Json = false, + }); + + Assert.Equal(0, exitCode); + string output = stdout.ToString(); + Assert.Contains("Input: alpha", output); + Assert.Contains("Input: bravo", output); + Assert.Contains("Input: charlie", output); + } + finally { Console.SetOut(origOut); } + } + + // ── JSON output ─────────────────────────────────────────────────── + + [Fact] + public void Execute_JsonMode_ContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + int exitCode = HashHandler.Execute(new HashOptions + { + Inputs = ["vehicle"], + Encoding = "utf8", + Json = true, + }); + + Assert.Equal(0, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": true", output); + Assert.Contains("\"input\": \"vehicle\"", output); + Assert.Contains("\"hash\":", output); + Assert.Contains("\"hashHex\":", output); + Assert.Contains("\"encoding\":", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void Execute_JsonMode_MultipleInputs_HasMultipleEntries() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + int exitCode = HashHandler.Execute(new HashOptions + { + Inputs = ["one", "two"], + Encoding = "utf8", + Json = true, + }); + + Assert.Equal(0, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"input\": \"one\"", output); + Assert.Contains("\"input\": \"two\"", output); + } + finally { Console.SetOut(origOut); } + } + + // ── Deterministic hashes ────────────────────────────────────────── + + [Fact] + public void Execute_SameInput_ProducesSameHash() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout1 = new(); + Console.SetOut(stdout1); + _ = HashHandler.Execute(new HashOptions + { + Inputs = ["deterministic"], + Encoding = "utf8", + Json = true, + }); + + StringWriter stdout2 = new(); + Console.SetOut(stdout2); + _ = HashHandler.Execute(new HashOptions + { + Inputs = ["deterministic"], + Encoding = "utf8", + Json = true, + }); + + Assert.Equal(stdout1.ToString(), stdout2.ToString()); + } + finally { Console.SetOut(origOut); } + } + + // ── Edge cases ──────────────────────────────────────────────────── + + [Fact] + public void Execute_EmptyStringInput_ReturnsZero() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + int exitCode = HashHandler.Execute(new HashOptions + { + Inputs = [""], + Encoding = "utf8", + Json = false, + }); + + Assert.Equal(0, exitCode); + Assert.Contains("Input: ", stdout.ToString()); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void Execute_CancellationToken_ThrowsOperationCanceled() + { + using System.Threading.CancellationTokenSource cts = new(); + cts.Cancel(); + + TextWriter origOut = Console.Out; + try + { + Console.SetOut(new StringWriter()); + _ = Assert.Throws(() => + HashHandler.Execute(new HashOptions + { + Inputs = ["test"], + Encoding = "utf8", + Json = false, + }, cts.Token) + ); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void Execute_DifferentInputs_ProduceDifferentHashes() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout1 = new(); + Console.SetOut(stdout1); + _ = HashHandler.Execute(new HashOptions + { + Inputs = ["alpha"], + Encoding = "utf8", + Json = true, + }); + + StringWriter stdout2 = new(); + Console.SetOut(stdout2); + _ = HashHandler.Execute(new HashOptions + { + Inputs = ["beta"], + Encoding = "utf8", + Json = true, + }); + + Assert.NotEqual(stdout1.ToString(), stdout2.ToString()); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void Execute_Json_ContainsHashSigned() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = HashHandler.Execute(new HashOptions + { + Inputs = ["test"], + Encoding = "utf8", + Json = true, + }); + + string output = stdout.ToString(); + Assert.Contains("\"hashSigned\":", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void Execute_TextMode_ShowsIntHash() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = HashHandler.Execute(new HashOptions + { + Inputs = ["test"], + Encoding = "utf8", + Json = false, + }); + + string output = stdout.ToString(); + Assert.Contains("Hash (int):", output); + } + finally { Console.SetOut(origOut); } + } +} diff --git a/CodeWalker.Cli/Tests/InspectHandlerTests.cs b/CodeWalker.Cli/Tests/InspectHandlerTests.cs new file mode 100644 index 000000000..8a2904373 --- /dev/null +++ b/CodeWalker.Cli/Tests/InspectHandlerTests.cs @@ -0,0 +1,140 @@ +using System; +using System.IO; + +using CodeWalker.Cli.Helpers; + +using SharpDX; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +public sealed class InspectHandlerTests +{ + // ── FormatVector3 ───────────────────────────────────────────────── + + [Fact] + public void FormatVector3_Zero_ReturnsFormattedZeros() => + Assert.Equal("0.00, 0.00, 0.00", InspectHandler.FormatVector3(Vector3.Zero)); + + [Fact] + public void FormatVector3_PositiveIntegers() => + Assert.Equal("1.00, 2.00, 3.00", InspectHandler.FormatVector3(new Vector3(1, 2, 3))); + + [Fact] + public void FormatVector3_NegativeValues() => + Assert.Equal("-1.50, -2.75, -3.00", InspectHandler.FormatVector3(new Vector3(-1.5f, -2.75f, -3f))); + + [Fact] + public void FormatVector3_FractionalValues_TwoDecimalPlaces() + { + string result = InspectHandler.FormatVector3(new Vector3(1.123f, 2.567f, 3.999f)); + // F2 rounds to 2 decimal places + Assert.Equal("1.12, 2.57, 4.00", result); + } + + [Fact] + public void FormatVector3_LargeValues() => + Assert.Equal("1000.00, -5000.00, 9999.99", + InspectHandler.FormatVector3(new Vector3(1000f, -5000f, 9999.99f))); + + [Fact] + public void FormatVector3_VerySmallValues() => + Assert.Equal("0.01, 0.00, -0.01", + InspectHandler.FormatVector3(new Vector3(0.01f, 0.001f, -0.01f))); + + // ── Additional FormatVector3 edge cases ─────────────────────────── + + [Fact] + public void FormatVector3_OneComponent() => + Assert.Equal("1.00, 0.00, 0.00", InspectHandler.FormatVector3(Vector3.UnitX)); + + [Fact] + public void FormatVector3_AllNegative() => + Assert.Equal("-1.00, -1.00, -1.00", InspectHandler.FormatVector3(new Vector3(-1, -1, -1))); +} + +[Collection("ConsoleOutput")] +public sealed class InspectHandlerExecuteTests +{ + private static RpfOptions MakeOptions(string rpfPath, bool json) => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, + }; + + [Fact] + public void Execute_MissingRpf_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = InspectHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), "some/file.ydr"); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_MissingRpf_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = InspectHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "some/file.ydr"); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("RPF file not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = InspectHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "some/file.ydr"); + + string output = stdout.ToString(); + Assert.Contains("\"rpfFile\":", output); + Assert.Contains("\"path\":", output); + Assert.Contains("\"size\": 0", output); + } + finally { Console.SetOut(origOut); } + } +} diff --git a/CodeWalker.Cli/Tests/ListHandlerTests.cs b/CodeWalker.Cli/Tests/ListHandlerTests.cs new file mode 100644 index 000000000..0919be8a1 --- /dev/null +++ b/CodeWalker.Cli/Tests/ListHandlerTests.cs @@ -0,0 +1,128 @@ +using System; +using System.IO; + +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +[Collection("ConsoleOutput")] +public sealed class ListHandlerTests +{ + private static RpfOptions MakeOptions(string rpfPath, bool json) => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, + }; + + // ── Validation failures ──────────────────────────────────────────── + + [Fact] + public void Execute_MissingRpf_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = ListHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false)); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_MissingRpf_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = ListHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("RPF file not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = ListHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + + string output = stdout.ToString(); + Assert.Contains("\"rpfFile\":", output); + Assert.Contains("\"totalFiles\": 0", output); + Assert.Contains("\"totalSize\": 0", output); + Assert.Contains("\"totalSizeFormatted\": \"0 B\"", output); + Assert.Contains("\"nestedRpfCount\": 0", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void Execute_MissingExe_WithExistingRpf_ReturnsOne() + { + string dir = Path.Combine(Path.GetTempPath(), "cw_list_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(dir); + string rpf = Path.Combine(dir, "test.rpf"); + File.WriteAllBytes(rpf, []); + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + StringWriter stderr = new(); + Console.SetError(stderr); + + int exitCode = ListHandler.Execute(MakeOptions(rpf, json: false)); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally { Directory.Delete(dir, true); } + } +} diff --git a/CodeWalker.Cli/Tests/PackHandlerTests.cs b/CodeWalker.Cli/Tests/PackHandlerTests.cs new file mode 100644 index 000000000..d2c5acbc2 --- /dev/null +++ b/CodeWalker.Cli/Tests/PackHandlerTests.cs @@ -0,0 +1,216 @@ +using System; +using System.IO; + +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +[Collection("ConsoleOutput")] +public sealed class PackHandlerTests +{ + private static PackOptions MakeOptions( + string inputPath, + string outputPath, + bool json, + string exePath = "/nonexistent", + bool force = false + ) => + new() + { + InputPath = inputPath, + OutputPath = outputPath, + Common = new CommonOptions + { + ExePath = exePath, + Verbose = false, + Json = json, + SizeFormat = SizeFormat.IEC, + Threads = 1, + }, + Gen9 = false, + Force = force, + Progress = false, + }; + + private static string CreateTempDir() + { + string dir = Path.Combine(Path.GetTempPath(), "cw_pack_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(dir); + return dir; + } + + // ── Input dir missing ────────────────────────────────────────────── + + [Fact] + public void Execute_InputDirMissing_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = PackHandler.Execute(MakeOptions("/nonexistent/input", "/tmp/out.rpf", json: false)); + + Assert.Equal(1, exitCode); + Assert.Contains("Input directory not found", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_InputDirMissing_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = PackHandler.Execute(MakeOptions("/nonexistent/input", "/tmp/out.rpf", json: true)); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("Input directory not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + // ── Output file exists without --force ───────────────────────────── + + [Fact] + public void Execute_OutputExists_NoForce_ReturnsOne() + { + string inputDir = CreateTempDir(); + string outputFile = Path.Combine(inputDir, "output.rpf"); + File.WriteAllBytes(outputFile, []); + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = PackHandler.Execute( + MakeOptions(inputDir, outputFile, json: false, force: false) + ); + + Assert.Equal(1, exitCode); + Assert.Contains("already exists", stderr.ToString()); + Assert.Contains("--force", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally { Directory.Delete(inputDir, true); } + } + + [Fact] + public void Execute_OutputExists_NoForce_Json_ReturnsErrorJson() + { + string inputDir = CreateTempDir(); + string outputFile = Path.Combine(inputDir, "output.rpf"); + File.WriteAllBytes(outputFile, []); + try + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + int exitCode = PackHandler.Execute( + MakeOptions(inputDir, outputFile, json: true, force: false) + ); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("already exists", output); + } + finally { Console.SetOut(origOut); } + } + finally { Directory.Delete(inputDir, true); } + } + + // ── Missing exe ──────────────────────────────────────────────────── + + [Fact] + public void Execute_MissingExe_ReturnsOne() + { + string inputDir = CreateTempDir(); + string outputFile = Path.Combine(Path.GetTempPath(), "cw_pack_out_" + Guid.NewGuid().ToString("N") + ".rpf"); + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = PackHandler.Execute(MakeOptions(inputDir, outputFile, json: false)); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally + { + Directory.Delete(inputDir, true); + if (File.Exists(outputFile)) + File.Delete(outputFile); + } + } + + // ── JSON error structure ─────────────────────────────────────────── + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = PackHandler.Execute(MakeOptions("/nonexistent/input", "/tmp/out.rpf", json: true)); + + string output = stdout.ToString(); + Assert.Contains("\"inputDir\":", output); + Assert.Contains("\"outputFile\":", output); + Assert.Contains("\"totalFiles\": 0", output); + Assert.Contains("\"totalDirs\": 0", output); + Assert.Contains("\"totalSize\": 0", output); + Assert.Contains("\"errors\": 0", output); + } + finally { Console.SetOut(origOut); } + } +} diff --git a/CodeWalker.Cli/Tests/SearchHandlerTests.cs b/CodeWalker.Cli/Tests/SearchHandlerTests.cs new file mode 100644 index 000000000..f350eeea7 --- /dev/null +++ b/CodeWalker.Cli/Tests/SearchHandlerTests.cs @@ -0,0 +1,152 @@ +using System; +using System.IO; + +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +public sealed class SearchHandlerTests +{ + // ── HasGlobChars ────────────────────────────────────────────────── + + [Fact] + public void HasGlobChars_WithAsterisk_ReturnsTrue() => + Assert.True(SearchHandler.HasGlobChars("*.ydr")); + + [Fact] + public void HasGlobChars_WithQuestionMark_ReturnsTrue() => + Assert.True(SearchHandler.HasGlobChars("file?.txt")); + + [Fact] + public void HasGlobChars_WithBoth_ReturnsTrue() => + Assert.True(SearchHandler.HasGlobChars("**/dir?.txt")); + + [Fact] + public void HasGlobChars_PlainString_ReturnsFalse() => + Assert.False(SearchHandler.HasGlobChars("vehicles")); + + [Fact] + public void HasGlobChars_Empty_ReturnsFalse() => + Assert.False(SearchHandler.HasGlobChars("")); + + [Fact] + public void HasGlobChars_HexHash_ReturnsFalse() => + Assert.False(SearchHandler.HasGlobChars("0xABCD1234")); + + [Fact] + public void HasGlobChars_DecimalNumber_ReturnsFalse() => + Assert.False(SearchHandler.HasGlobChars("123456789")); + + [Fact] + public void HasGlobChars_PathWithoutGlob_ReturnsFalse() => + Assert.False(SearchHandler.HasGlobChars("vehicles/adder.ydr")); + + [Fact] + public void HasGlobChars_GlobstarPattern_ReturnsTrue() => + Assert.True(SearchHandler.HasGlobChars("**/vehicles/*.ydr")); + + [Fact] + public void HasGlobChars_QuestionMarkOnly_ReturnsTrue() => + Assert.True(SearchHandler.HasGlobChars("?")); + + [Fact] + public void HasGlobChars_AsteriskOnly_ReturnsTrue() => + Assert.True(SearchHandler.HasGlobChars("*")); + + // ── Additional HasGlobChars edge cases ──────────────────────────── + + [Fact] + public void HasGlobChars_BracketPattern_ReturnsFalse() => + Assert.False(SearchHandler.HasGlobChars("[abc]")); + + [Fact] + public void HasGlobChars_AsteriskInMiddle_ReturnsTrue() => + Assert.True(SearchHandler.HasGlobChars("foo*bar")); +} + +[Collection("ConsoleOutput")] +public sealed class SearchHandlerExecuteTests +{ + private static RpfOptions MakeOptions(string rpfPath, bool json) => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, + }; + + [Fact] + public void Execute_MissingRpf_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), "*.ydr"); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_MissingRpf_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "adder"); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("RPF file not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "test*"); + + string output = stdout.ToString(); + Assert.Contains("\"rpfFile\":", output); + Assert.Contains("\"pattern\":", output); + Assert.Contains("\"matchCount\": 0", output); + } + finally { Console.SetOut(origOut); } + } +} diff --git a/CodeWalker.Cli/Tests/StatHandlerTests.cs b/CodeWalker.Cli/Tests/StatHandlerTests.cs new file mode 100644 index 000000000..d7a221029 --- /dev/null +++ b/CodeWalker.Cli/Tests/StatHandlerTests.cs @@ -0,0 +1,129 @@ +using System; +using System.IO; + +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +[Collection("ConsoleOutput")] +public sealed class StatHandlerTests +{ + private static RpfOptions MakeOptions(string rpfPath, bool json) => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, + }; + + // ── Validation failures ──────────────────────────────────────────── + + [Fact] + public void Execute_MissingRpf_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = StatHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false)); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_MissingRpf_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = StatHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("RPF file not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = StatHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + + string output = stdout.ToString(); + Assert.Contains("\"rpfFile\":", output); + Assert.Contains("\"totalFiles\": 0", output); + Assert.Contains("\"totalSize\": 0", output); + Assert.Contains("\"resourceCount\": 0", output); + Assert.Contains("\"binaryCount\": 0", output); + Assert.Contains("\"compressionRatio\": 0", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void Execute_MissingExe_WithExistingRpf_ReturnsOne() + { + string dir = Path.Combine(Path.GetTempPath(), "cw_stat_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(dir); + string rpf = Path.Combine(dir, "test.rpf"); + File.WriteAllBytes(rpf, []); + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + StringWriter stderr = new(); + Console.SetError(stderr); + + int exitCode = StatHandler.Execute(MakeOptions(rpf, json: false)); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally { Directory.Delete(dir, true); } + } +} diff --git a/CodeWalker.Cli/Tests/TreeHandlerTests.cs b/CodeWalker.Cli/Tests/TreeHandlerTests.cs new file mode 100644 index 000000000..85e0f595a --- /dev/null +++ b/CodeWalker.Cli/Tests/TreeHandlerTests.cs @@ -0,0 +1,131 @@ +using System; +using System.IO; + +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +[Collection("ConsoleOutput")] +public sealed class TreeHandlerTests +{ + private static TreeOptions MakeOptions(string rpfPath, bool json, int depth = -1) => + new() + { + Rpf = new RpfOptions + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, + }, + Depth = depth, + }; + + // ── Validation failures ──────────────────────────────────────────── + + [Fact] + public void Execute_MissingRpf_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = TreeHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false)); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_MissingRpf_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = TreeHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("RPF file not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = TreeHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + + string output = stdout.ToString(); + Assert.Contains("\"rpfFile\":", output); + Assert.Contains("\"totalFiles\": 0", output); + Assert.Contains("\"totalDirs\": 0", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void Execute_MissingExe_WithExistingRpf_ReturnsOne() + { + string dir = Path.Combine(Path.GetTempPath(), "cw_tree_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(dir); + string rpf = Path.Combine(dir, "test.rpf"); + File.WriteAllBytes(rpf, []); + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + StringWriter stderr = new(); + Console.SetError(stderr); + + TreeOptions options = MakeOptions(rpf, json: false); + int exitCode = TreeHandler.Execute(options); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally { Directory.Delete(dir, true); } + } +} diff --git a/CodeWalker.Cli/Tests/ValidateHandlerTests.cs b/CodeWalker.Cli/Tests/ValidateHandlerTests.cs new file mode 100644 index 000000000..4366816b3 --- /dev/null +++ b/CodeWalker.Cli/Tests/ValidateHandlerTests.cs @@ -0,0 +1,133 @@ +using System; +using System.IO; + +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +[Collection("ConsoleOutput")] +public sealed class ValidateHandlerTests +{ + private static ValidateOptions MakeOptions(string rpfPath, bool json) => + new() + { + Rpf = new RpfOptions + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, + }, + Progress = false, + }; + + // ── Validation failures ──────────────────────────────────────────── + + [Fact] + public void Execute_MissingRpf_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = ValidateHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false)); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_MissingRpf_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = ValidateHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("RPF file not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = ValidateHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + + string output = stdout.ToString(); + Assert.Contains("\"rpfFile\":", output); + Assert.Contains("\"totalFiles\": 0", output); + Assert.Contains("\"valid\": 0", output); + Assert.Contains("\"warnings\": 0", output); + Assert.Contains("\"errors\": 0", output); + Assert.Contains("\"skipped\": 0", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void Execute_MissingExe_WithExistingRpf_ReturnsOne() + { + string dir = Path.Combine(Path.GetTempPath(), "cw_val_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(dir); + string rpf = Path.Combine(dir, "test.rpf"); + File.WriteAllBytes(rpf, []); + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + StringWriter stderr = new(); + Console.SetError(stderr); + + int exitCode = ValidateHandler.Execute(MakeOptions(rpf, json: false)); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally { Directory.Delete(dir, true); } + } +} From 7f218fbd7851d5c66a56678a41d1aed32d6845d6 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:26:53 +0200 Subject: [PATCH 16/45] chore(cli): build the net48 target on non-Windows Building net48 outside Windows needs the reference assemblies as a package, since there is no .NET Framework install to reference. Without it the whole project could only be built on Windows, and the net48 target is exactly the one most likely to break. The tests still only run under Mono on Linux, but they do run. --- CodeWalker.Cli/Directory.Build.props | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/CodeWalker.Cli/Directory.Build.props b/CodeWalker.Cli/Directory.Build.props index 9d80d396d..fbc076043 100644 --- a/CodeWalker.Cli/Directory.Build.props +++ b/CodeWalker.Cli/Directory.Build.props @@ -2,8 +2,7 @@ Exe - net48;net8.0;net10.0 - net8.0;net10.0 + net48;net8.0;net10.0 latest enable disable @@ -15,6 +14,12 @@ + Date: Wed, 2 Sep 2026 09:26:53 +0200 Subject: [PATCH 17/45] test(cli): thread cancellation through the tests Every handler test called Execute with CancellationToken.None, so a test that hung had nothing to stop it and the runner's own timeout was the only backstop. They pass TestContext.Current.CancellationToken. The polyfill fuzz tests gain the edge cases the framework methods special-case: empty needles, empty haystacks, and a needle longer than what it is searched in. --- CodeWalker.Cli/Polyfills.cs | 14 +++------ CodeWalker.Cli/Tests/DiffHandlerTests.cs | 9 +++--- CodeWalker.Cli/Tests/ExportServiceTests.cs | 7 +++-- CodeWalker.Cli/Tests/ExtractHandlerTests.cs | 11 ++++--- CodeWalker.Cli/Tests/Gen9HandlerTests.cs | 13 ++++---- CodeWalker.Cli/Tests/HashHandlerTests.cs | 33 ++++++++++---------- CodeWalker.Cli/Tests/InspectHandlerTests.cs | 7 +++-- CodeWalker.Cli/Tests/ListHandlerTests.cs | 9 +++--- CodeWalker.Cli/Tests/PackHandlerTests.cs | 15 +++++---- CodeWalker.Cli/Tests/PolyfillsTests.cs | 6 ++-- CodeWalker.Cli/Tests/SearchHandlerTests.cs | 7 +++-- CodeWalker.Cli/Tests/StatHandlerTests.cs | 9 +++--- CodeWalker.Cli/Tests/TreeHandlerTests.cs | 9 +++--- CodeWalker.Cli/Tests/ValidateHandlerTests.cs | 9 +++--- 14 files changed, 84 insertions(+), 74 deletions(-) diff --git a/CodeWalker.Cli/Polyfills.cs b/CodeWalker.Cli/Polyfills.cs index b76401f55..711f657b0 100644 --- a/CodeWalker.Cli/Polyfills.cs +++ b/CodeWalker.Cli/Polyfills.cs @@ -107,15 +107,9 @@ private static int FindMatchLength( if (index < 0) return 0; - // Fast path: most matches consume exactly value.Length characters - if (index + value.Length <= source.Length - && compareInfo.Compare(source, index, value.Length, value, 0, value.Length, options) == 0) - { - return value.Length; - } - - // Slow path: cultural normalization means the matched span differs - // from value.Length (e.g. zero-weight characters like \0) + // Find the actual span length that culturally matches 'value'. + // Usually len == value.Length, but zero-weight characters (e.g. \u00AD + // on .NET Framework) can make the matched span shorter or longer. int maxLen = source.Length - index; for (int len = 1; len <= maxLen; len++) { @@ -123,7 +117,7 @@ private static int FindMatchLength( return len; } - return value.Length; // fallback (should be unreachable if IndexOf found a match) + return value.Length; // unreachable: IndexOf guarantees a match exists } public static string Replace(this string s, string oldValue, string? newValue, StringComparison comparisonType) diff --git a/CodeWalker.Cli/Tests/DiffHandlerTests.cs b/CodeWalker.Cli/Tests/DiffHandlerTests.cs index e9b0a723d..c1a8ac62e 100644 --- a/CodeWalker.Cli/Tests/DiffHandlerTests.cs +++ b/CodeWalker.Cli/Tests/DiffHandlerTests.cs @@ -4,6 +4,7 @@ using CodeWalker.Cli.Helpers; using Xunit; +using Xunit.v3; namespace CodeWalker.Cli.Tests; @@ -110,7 +111,7 @@ public void Execute_LeftMissing_ReturnsOne() Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = DiffHandler.Execute(MakeOptions("/nonexistent/left.rpf", "/nonexistent/right.rpf", json: false)); + int exitCode = DiffHandler.Execute(MakeOptions("/nonexistent/left.rpf", "/nonexistent/right.rpf", json: false), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); @@ -133,7 +134,7 @@ public void Execute_LeftMissing_Json_ReturnsErrorJson() Console.SetOut(stdout); Console.SetError(new StringWriter()); - int exitCode = DiffHandler.Execute(MakeOptions("/nonexistent/left.rpf", "/nonexistent/right.rpf", json: true)); + int exitCode = DiffHandler.Execute(MakeOptions("/nonexistent/left.rpf", "/nonexistent/right.rpf", json: true), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); string output = stdout.ToString(); @@ -182,7 +183,7 @@ public void Execute_RightMissing_WithExistingLeft_ReturnsOne() Recursive = false, }; - int exitCode = DiffHandler.Execute(options); + int exitCode = DiffHandler.Execute(options, TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("RPF file not found", stderr.ToString()); @@ -205,7 +206,7 @@ public void Execute_Json_ErrorContainsExpectedFields() StringWriter stdout = new(); Console.SetOut(stdout); - _ = DiffHandler.Execute(MakeOptions("/nonexistent/left.rpf", "/nonexistent/right.rpf", json: true)); + _ = DiffHandler.Execute(MakeOptions("/nonexistent/left.rpf", "/nonexistent/right.rpf", json: true), TestContext.Current.CancellationToken); string output = stdout.ToString(); Assert.Contains("\"leftRpf\":", output); diff --git a/CodeWalker.Cli/Tests/ExportServiceTests.cs b/CodeWalker.Cli/Tests/ExportServiceTests.cs index d5b243d67..fc2bb5cc6 100644 --- a/CodeWalker.Cli/Tests/ExportServiceTests.cs +++ b/CodeWalker.Cli/Tests/ExportServiceTests.cs @@ -5,6 +5,7 @@ using CodeWalker.GameFiles; using Xunit; +using Xunit.v3; namespace CodeWalker.Cli.Tests; @@ -44,7 +45,7 @@ public void Execute_ReturnsOne_WhenValidationFails_TextMode() StringWriter stderr = new(); Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = ExportService.Execute(MakeOptions(json: false), "xml", "XML", NoOpProcessor); + int exitCode = ExportService.Execute(MakeOptions(json: false), "xml", "XML", NoOpProcessor, TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); } @@ -65,7 +66,7 @@ public void Execute_ReturnsOne_WhenValidationFails_JsonMode() StringWriter stdout = new(); Console.SetOut(stdout); Console.SetError(new StringWriter()); - int exitCode = ExportService.Execute(MakeOptions(json: true), "xml", "XML", NoOpProcessor); + int exitCode = ExportService.Execute(MakeOptions(json: true), "xml", "XML", NoOpProcessor, TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); string output = stdout.ToString(); Assert.Contains("\"success\": false", output); @@ -86,7 +87,7 @@ public void Execute_JsonError_ContainsExpectedFields() { StringWriter stdout = new(); Console.SetOut(stdout); - int exitCode = ExportService.Execute(MakeOptions(json: true), "textures", "Textures", NoOpProcessor); + int exitCode = ExportService.Execute(MakeOptions(json: true), "textures", "Textures", NoOpProcessor, TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); string output = stdout.ToString(); Assert.Contains("\"format\": \"textures\"", output); diff --git a/CodeWalker.Cli/Tests/ExtractHandlerTests.cs b/CodeWalker.Cli/Tests/ExtractHandlerTests.cs index ffb4d7852..3f6935957 100644 --- a/CodeWalker.Cli/Tests/ExtractHandlerTests.cs +++ b/CodeWalker.Cli/Tests/ExtractHandlerTests.cs @@ -4,6 +4,7 @@ using CodeWalker.Cli.Helpers; using Xunit; +using Xunit.v3; namespace CodeWalker.Cli.Tests; @@ -44,7 +45,7 @@ public void Execute_MissingRpf_ReturnsOne() Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = ExtractHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false)); + int exitCode = ExtractHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); @@ -67,7 +68,7 @@ public void Execute_MissingRpf_Json_ReturnsErrorJson() Console.SetOut(stdout); Console.SetError(new StringWriter()); - int exitCode = ExtractHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + int exitCode = ExtractHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); string output = stdout.ToString(); @@ -90,7 +91,7 @@ public void Execute_Json_ErrorContainsExpectedFields() StringWriter stdout = new(); Console.SetOut(stdout); - _ = ExtractHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + _ = ExtractHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); string output = stdout.ToString(); Assert.Contains("\"rpfFile\":", output); @@ -120,7 +121,7 @@ public void Execute_MissingExe_WithExistingRpf_ReturnsOne() StringWriter stderr = new(); Console.SetError(stderr); - int exitCode = ExtractHandler.Execute(MakeOptions(rpf, json: false)); + int exitCode = ExtractHandler.Execute(MakeOptions(rpf, json: false), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); @@ -143,7 +144,7 @@ public void Execute_DryRun_Json_ErrorStillHasDryRunTrue() StringWriter stdout = new(); Console.SetOut(stdout); - _ = ExtractHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true, dryRun: true)); + _ = ExtractHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true, dryRun: true), TestContext.Current.CancellationToken); string output = stdout.ToString(); Assert.Contains("\"dryRun\": true", output); diff --git a/CodeWalker.Cli/Tests/Gen9HandlerTests.cs b/CodeWalker.Cli/Tests/Gen9HandlerTests.cs index d940970f7..442cdf03f 100644 --- a/CodeWalker.Cli/Tests/Gen9HandlerTests.cs +++ b/CodeWalker.Cli/Tests/Gen9HandlerTests.cs @@ -4,6 +4,7 @@ using CodeWalker.Cli.Helpers; using Xunit; +using Xunit.v3; namespace CodeWalker.Cli.Tests; @@ -54,7 +55,7 @@ public void Execute_InputFolderMissing_ReturnsOne() Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = Gen9Handler.Execute(MakeOptions("/nonexistent/input", "/tmp/out", json: false)); + int exitCode = Gen9Handler.Execute(MakeOptions("/nonexistent/input", "/tmp/out", json: false), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); @@ -78,7 +79,7 @@ public void Execute_InputFolderMissing_Json_ReturnsErrorJson() Console.SetOut(stdout); Console.SetError(new StringWriter()); - int exitCode = Gen9Handler.Execute(MakeOptions("/nonexistent/input", "/tmp/out", json: true)); + int exitCode = Gen9Handler.Execute(MakeOptions("/nonexistent/input", "/tmp/out", json: true), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); string output = stdout.ToString(); @@ -108,7 +109,7 @@ public void Execute_InputEqualsOutput_ReturnsOne() Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = Gen9Handler.Execute(MakeOptions(dir, dir, json: false)); + int exitCode = Gen9Handler.Execute(MakeOptions(dir, dir, json: false), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("must be different", stderr.ToString()); @@ -134,7 +135,7 @@ public void Execute_InputEqualsOutput_Json_ReturnsErrorJson() StringWriter stdout = new(); Console.SetOut(stdout); - int exitCode = Gen9Handler.Execute(MakeOptions(dir, dir, json: true)); + int exitCode = Gen9Handler.Execute(MakeOptions(dir, dir, json: true), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); string output = stdout.ToString(); @@ -163,7 +164,7 @@ public void Execute_MissingExe_ReturnsOne() Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = Gen9Handler.Execute(MakeOptions(inputDir, outputDir, json: false)); + int exitCode = Gen9Handler.Execute(MakeOptions(inputDir, outputDir, json: false), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); @@ -193,7 +194,7 @@ public void Execute_Json_ErrorContainsExpectedFields() StringWriter stdout = new(); Console.SetOut(stdout); - _ = Gen9Handler.Execute(MakeOptions("/nonexistent/input", "/tmp/out", json: true)); + _ = Gen9Handler.Execute(MakeOptions("/nonexistent/input", "/tmp/out", json: true), TestContext.Current.CancellationToken); string output = stdout.ToString(); Assert.Contains("\"inputFolder\":", output); diff --git a/CodeWalker.Cli/Tests/HashHandlerTests.cs b/CodeWalker.Cli/Tests/HashHandlerTests.cs index acf9f7c05..f869413bb 100644 --- a/CodeWalker.Cli/Tests/HashHandlerTests.cs +++ b/CodeWalker.Cli/Tests/HashHandlerTests.cs @@ -2,6 +2,7 @@ using System.IO; using Xunit; +using Xunit.v3; namespace CodeWalker.Cli.Tests; @@ -26,7 +27,7 @@ public void Execute_UnknownEncoding_ReturnsOne() Inputs = ["test"], Encoding = "unknown-codec", Json = false, - }); + }, TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Unknown encoding", stderr.ToString()); @@ -52,7 +53,7 @@ public void Execute_UnknownEncoding_Json_ReturnsErrorJson() Inputs = ["test"], Encoding = "bad", Json = true, - }); + }, TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); string output = stdout.ToString(); @@ -78,7 +79,7 @@ public void Execute_Utf8Encoding_ReturnsZero() Inputs = ["test"], Encoding = "utf8", Json = false, - }); + }, TestContext.Current.CancellationToken); Assert.Equal(0, exitCode); string output = stdout.ToString(); @@ -102,7 +103,7 @@ public void Execute_Utf8WithDash_ReturnsZero() Inputs = ["test"], Encoding = "utf-8", Json = false, - }); + }, TestContext.Current.CancellationToken); Assert.Equal(0, exitCode); } @@ -122,7 +123,7 @@ public void Execute_AsciiEncoding_ReturnsZero() Inputs = ["hello"], Encoding = "ascii", Json = false, - }); + }, TestContext.Current.CancellationToken); Assert.Equal(0, exitCode); } @@ -142,7 +143,7 @@ public void Execute_EncodingIsCaseInsensitive() Inputs = ["test"], Encoding = "ASCII", Json = false, - }); + }, TestContext.Current.CancellationToken); Assert.Equal(0, exitCode); } @@ -165,7 +166,7 @@ public void Execute_MultipleInputs_HashesAll() Inputs = ["alpha", "bravo", "charlie"], Encoding = "utf8", Json = false, - }); + }, TestContext.Current.CancellationToken); Assert.Equal(0, exitCode); string output = stdout.ToString(); @@ -192,7 +193,7 @@ public void Execute_JsonMode_ContainsExpectedFields() Inputs = ["vehicle"], Encoding = "utf8", Json = true, - }); + }, TestContext.Current.CancellationToken); Assert.Equal(0, exitCode); string output = stdout.ToString(); @@ -219,7 +220,7 @@ public void Execute_JsonMode_MultipleInputs_HasMultipleEntries() Inputs = ["one", "two"], Encoding = "utf8", Json = true, - }); + }, TestContext.Current.CancellationToken); Assert.Equal(0, exitCode); string output = stdout.ToString(); @@ -244,7 +245,7 @@ public void Execute_SameInput_ProducesSameHash() Inputs = ["deterministic"], Encoding = "utf8", Json = true, - }); + }, TestContext.Current.CancellationToken); StringWriter stdout2 = new(); Console.SetOut(stdout2); @@ -253,7 +254,7 @@ public void Execute_SameInput_ProducesSameHash() Inputs = ["deterministic"], Encoding = "utf8", Json = true, - }); + }, TestContext.Current.CancellationToken); Assert.Equal(stdout1.ToString(), stdout2.ToString()); } @@ -276,7 +277,7 @@ public void Execute_EmptyStringInput_ReturnsZero() Inputs = [""], Encoding = "utf8", Json = false, - }); + }, TestContext.Current.CancellationToken); Assert.Equal(0, exitCode); Assert.Contains("Input: ", stdout.ToString()); @@ -319,7 +320,7 @@ public void Execute_DifferentInputs_ProduceDifferentHashes() Inputs = ["alpha"], Encoding = "utf8", Json = true, - }); + }, TestContext.Current.CancellationToken); StringWriter stdout2 = new(); Console.SetOut(stdout2); @@ -328,7 +329,7 @@ public void Execute_DifferentInputs_ProduceDifferentHashes() Inputs = ["beta"], Encoding = "utf8", Json = true, - }); + }, TestContext.Current.CancellationToken); Assert.NotEqual(stdout1.ToString(), stdout2.ToString()); } @@ -349,7 +350,7 @@ public void Execute_Json_ContainsHashSigned() Inputs = ["test"], Encoding = "utf8", Json = true, - }); + }, TestContext.Current.CancellationToken); string output = stdout.ToString(); Assert.Contains("\"hashSigned\":", output); @@ -371,7 +372,7 @@ public void Execute_TextMode_ShowsIntHash() Inputs = ["test"], Encoding = "utf8", Json = false, - }); + }, TestContext.Current.CancellationToken); string output = stdout.ToString(); Assert.Contains("Hash (int):", output); diff --git a/CodeWalker.Cli/Tests/InspectHandlerTests.cs b/CodeWalker.Cli/Tests/InspectHandlerTests.cs index 8a2904373..67d754597 100644 --- a/CodeWalker.Cli/Tests/InspectHandlerTests.cs +++ b/CodeWalker.Cli/Tests/InspectHandlerTests.cs @@ -6,6 +6,7 @@ using SharpDX; using Xunit; +using Xunit.v3; namespace CodeWalker.Cli.Tests; @@ -82,7 +83,7 @@ public void Execute_MissingRpf_ReturnsOne() Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = InspectHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), "some/file.ydr"); + int exitCode = InspectHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), "some/file.ydr", TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); @@ -105,7 +106,7 @@ public void Execute_MissingRpf_Json_ReturnsErrorJson() Console.SetOut(stdout); Console.SetError(new StringWriter()); - int exitCode = InspectHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "some/file.ydr"); + int exitCode = InspectHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "some/file.ydr", TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); string output = stdout.ToString(); @@ -128,7 +129,7 @@ public void Execute_Json_ErrorContainsExpectedFields() StringWriter stdout = new(); Console.SetOut(stdout); - _ = InspectHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "some/file.ydr"); + _ = InspectHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "some/file.ydr", TestContext.Current.CancellationToken); string output = stdout.ToString(); Assert.Contains("\"rpfFile\":", output); diff --git a/CodeWalker.Cli/Tests/ListHandlerTests.cs b/CodeWalker.Cli/Tests/ListHandlerTests.cs index 0919be8a1..dc3ecaaa1 100644 --- a/CodeWalker.Cli/Tests/ListHandlerTests.cs +++ b/CodeWalker.Cli/Tests/ListHandlerTests.cs @@ -4,6 +4,7 @@ using CodeWalker.Cli.Helpers; using Xunit; +using Xunit.v3; namespace CodeWalker.Cli.Tests; @@ -37,7 +38,7 @@ public void Execute_MissingRpf_ReturnsOne() Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = ListHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false)); + int exitCode = ListHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); @@ -60,7 +61,7 @@ public void Execute_MissingRpf_Json_ReturnsErrorJson() Console.SetOut(stdout); Console.SetError(new StringWriter()); - int exitCode = ListHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + int exitCode = ListHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); string output = stdout.ToString(); @@ -83,7 +84,7 @@ public void Execute_Json_ErrorContainsExpectedFields() StringWriter stdout = new(); Console.SetOut(stdout); - _ = ListHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + _ = ListHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); string output = stdout.ToString(); Assert.Contains("\"rpfFile\":", output); @@ -112,7 +113,7 @@ public void Execute_MissingExe_WithExistingRpf_ReturnsOne() StringWriter stderr = new(); Console.SetError(stderr); - int exitCode = ListHandler.Execute(MakeOptions(rpf, json: false)); + int exitCode = ListHandler.Execute(MakeOptions(rpf, json: false), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); diff --git a/CodeWalker.Cli/Tests/PackHandlerTests.cs b/CodeWalker.Cli/Tests/PackHandlerTests.cs index d2c5acbc2..e3c32a593 100644 --- a/CodeWalker.Cli/Tests/PackHandlerTests.cs +++ b/CodeWalker.Cli/Tests/PackHandlerTests.cs @@ -4,6 +4,7 @@ using CodeWalker.Cli.Helpers; using Xunit; +using Xunit.v3; namespace CodeWalker.Cli.Tests; @@ -54,7 +55,7 @@ public void Execute_InputDirMissing_ReturnsOne() Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = PackHandler.Execute(MakeOptions("/nonexistent/input", "/tmp/out.rpf", json: false)); + int exitCode = PackHandler.Execute(MakeOptions("/nonexistent/input", "/tmp/out.rpf", json: false), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Input directory not found", stderr.ToString()); @@ -77,7 +78,7 @@ public void Execute_InputDirMissing_Json_ReturnsErrorJson() Console.SetOut(stdout); Console.SetError(new StringWriter()); - int exitCode = PackHandler.Execute(MakeOptions("/nonexistent/input", "/tmp/out.rpf", json: true)); + int exitCode = PackHandler.Execute(MakeOptions("/nonexistent/input", "/tmp/out.rpf", json: true), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); string output = stdout.ToString(); @@ -110,7 +111,8 @@ public void Execute_OutputExists_NoForce_ReturnsOne() Console.SetError(stderr); int exitCode = PackHandler.Execute( - MakeOptions(inputDir, outputFile, json: false, force: false) + MakeOptions(inputDir, outputFile, json: false, force: false), + TestContext.Current.CancellationToken ); Assert.Equal(1, exitCode); @@ -141,7 +143,8 @@ public void Execute_OutputExists_NoForce_Json_ReturnsErrorJson() Console.SetOut(stdout); int exitCode = PackHandler.Execute( - MakeOptions(inputDir, outputFile, json: true, force: false) + MakeOptions(inputDir, outputFile, json: true, force: false), + TestContext.Current.CancellationToken ); Assert.Equal(1, exitCode); @@ -171,7 +174,7 @@ public void Execute_MissingExe_ReturnsOne() Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = PackHandler.Execute(MakeOptions(inputDir, outputFile, json: false)); + int exitCode = PackHandler.Execute(MakeOptions(inputDir, outputFile, json: false), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); @@ -201,7 +204,7 @@ public void Execute_Json_ErrorContainsExpectedFields() StringWriter stdout = new(); Console.SetOut(stdout); - _ = PackHandler.Execute(MakeOptions("/nonexistent/input", "/tmp/out.rpf", json: true)); + _ = PackHandler.Execute(MakeOptions("/nonexistent/input", "/tmp/out.rpf", json: true), TestContext.Current.CancellationToken); string output = stdout.ToString(); Assert.Contains("\"inputDir\":", output); diff --git a/CodeWalker.Cli/Tests/PolyfillsTests.cs b/CodeWalker.Cli/Tests/PolyfillsTests.cs index 64eabe079..f16dcbb84 100644 --- a/CodeWalker.Cli/Tests/PolyfillsTests.cs +++ b/CodeWalker.Cli/Tests/PolyfillsTests.cs @@ -33,6 +33,7 @@ private static string[] BuildCorpus() " spaces ", "tab\there", "new\nline", "straße", "STRASSE", "Straße", "café", "CAFÉ", "résumé", "naïve", "日本語", + "a\u00ADb", "a\u00ADbcd", "xa\u00ADbc", "a\0b", "he\u00ADllo", "abc123!@#", "path/to/file.txt", @"C:\Windows\System32", "\0null\0", "🎮🎲🎯", new string('x', 200), "aaa", "aaA", "AaA", @@ -65,7 +66,7 @@ private static string[] BuildCorpus() private static readonly string[] SearchStrings = [ "a", "A", "hello", "HELLO", "llo", "World", "world", - "straße", "STRASSE", "ß", "SS", "café", "xyz", " ", + "ab", "abc", "straße", "STRASSE", "ß", "SS", "café", "xyz", " ", "/", "\\", "\0", "🎮", "xx", ]; @@ -177,7 +178,8 @@ private static string Esc(string? s) => s?.Replace("\0", "\\0", StringComparison.Ordinal) .Replace("\n", "\\n", StringComparison.Ordinal) .Replace("\r", "\\r", StringComparison.Ordinal) - .Replace("\t", "\\t", StringComparison.Ordinal) ?? "(null)"; + .Replace("\t", "\\t", StringComparison.Ordinal) + .Replace("\u00AD", "\\u00AD", StringComparison.Ordinal) ?? "(null)"; } public sealed class StringExtensionsUnitTests diff --git a/CodeWalker.Cli/Tests/SearchHandlerTests.cs b/CodeWalker.Cli/Tests/SearchHandlerTests.cs index f350eeea7..c6d5a929d 100644 --- a/CodeWalker.Cli/Tests/SearchHandlerTests.cs +++ b/CodeWalker.Cli/Tests/SearchHandlerTests.cs @@ -4,6 +4,7 @@ using CodeWalker.Cli.Helpers; using Xunit; +using Xunit.v3; namespace CodeWalker.Cli.Tests; @@ -94,7 +95,7 @@ public void Execute_MissingRpf_ReturnsOne() Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), "*.ydr"); + int exitCode = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), "*.ydr", TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); @@ -117,7 +118,7 @@ public void Execute_MissingRpf_Json_ReturnsErrorJson() Console.SetOut(stdout); Console.SetError(new StringWriter()); - int exitCode = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "adder"); + int exitCode = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "adder", TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); string output = stdout.ToString(); @@ -140,7 +141,7 @@ public void Execute_Json_ErrorContainsExpectedFields() StringWriter stdout = new(); Console.SetOut(stdout); - _ = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "test*"); + _ = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "test*", TestContext.Current.CancellationToken); string output = stdout.ToString(); Assert.Contains("\"rpfFile\":", output); diff --git a/CodeWalker.Cli/Tests/StatHandlerTests.cs b/CodeWalker.Cli/Tests/StatHandlerTests.cs index d7a221029..5e39db953 100644 --- a/CodeWalker.Cli/Tests/StatHandlerTests.cs +++ b/CodeWalker.Cli/Tests/StatHandlerTests.cs @@ -4,6 +4,7 @@ using CodeWalker.Cli.Helpers; using Xunit; +using Xunit.v3; namespace CodeWalker.Cli.Tests; @@ -37,7 +38,7 @@ public void Execute_MissingRpf_ReturnsOne() Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = StatHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false)); + int exitCode = StatHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); @@ -60,7 +61,7 @@ public void Execute_MissingRpf_Json_ReturnsErrorJson() Console.SetOut(stdout); Console.SetError(new StringWriter()); - int exitCode = StatHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + int exitCode = StatHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); string output = stdout.ToString(); @@ -83,7 +84,7 @@ public void Execute_Json_ErrorContainsExpectedFields() StringWriter stdout = new(); Console.SetOut(stdout); - _ = StatHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + _ = StatHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); string output = stdout.ToString(); Assert.Contains("\"rpfFile\":", output); @@ -113,7 +114,7 @@ public void Execute_MissingExe_WithExistingRpf_ReturnsOne() StringWriter stderr = new(); Console.SetError(stderr); - int exitCode = StatHandler.Execute(MakeOptions(rpf, json: false)); + int exitCode = StatHandler.Execute(MakeOptions(rpf, json: false), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); diff --git a/CodeWalker.Cli/Tests/TreeHandlerTests.cs b/CodeWalker.Cli/Tests/TreeHandlerTests.cs index 85e0f595a..c46d91a9b 100644 --- a/CodeWalker.Cli/Tests/TreeHandlerTests.cs +++ b/CodeWalker.Cli/Tests/TreeHandlerTests.cs @@ -4,6 +4,7 @@ using CodeWalker.Cli.Helpers; using Xunit; +using Xunit.v3; namespace CodeWalker.Cli.Tests; @@ -41,7 +42,7 @@ public void Execute_MissingRpf_ReturnsOne() Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = TreeHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false)); + int exitCode = TreeHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); @@ -64,7 +65,7 @@ public void Execute_MissingRpf_Json_ReturnsErrorJson() Console.SetOut(stdout); Console.SetError(new StringWriter()); - int exitCode = TreeHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + int exitCode = TreeHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); string output = stdout.ToString(); @@ -87,7 +88,7 @@ public void Execute_Json_ErrorContainsExpectedFields() StringWriter stdout = new(); Console.SetOut(stdout); - _ = TreeHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + _ = TreeHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); string output = stdout.ToString(); Assert.Contains("\"rpfFile\":", output); @@ -115,7 +116,7 @@ public void Execute_MissingExe_WithExistingRpf_ReturnsOne() Console.SetError(stderr); TreeOptions options = MakeOptions(rpf, json: false); - int exitCode = TreeHandler.Execute(options); + int exitCode = TreeHandler.Execute(options, TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); diff --git a/CodeWalker.Cli/Tests/ValidateHandlerTests.cs b/CodeWalker.Cli/Tests/ValidateHandlerTests.cs index 4366816b3..2e9951a0c 100644 --- a/CodeWalker.Cli/Tests/ValidateHandlerTests.cs +++ b/CodeWalker.Cli/Tests/ValidateHandlerTests.cs @@ -4,6 +4,7 @@ using CodeWalker.Cli.Helpers; using Xunit; +using Xunit.v3; namespace CodeWalker.Cli.Tests; @@ -41,7 +42,7 @@ public void Execute_MissingRpf_ReturnsOne() Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = ValidateHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false)); + int exitCode = ValidateHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); @@ -64,7 +65,7 @@ public void Execute_MissingRpf_Json_ReturnsErrorJson() Console.SetOut(stdout); Console.SetError(new StringWriter()); - int exitCode = ValidateHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + int exitCode = ValidateHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); string output = stdout.ToString(); @@ -87,7 +88,7 @@ public void Execute_Json_ErrorContainsExpectedFields() StringWriter stdout = new(); Console.SetOut(stdout); - _ = ValidateHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true)); + _ = ValidateHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); string output = stdout.ToString(); Assert.Contains("\"rpfFile\":", output); @@ -117,7 +118,7 @@ public void Execute_MissingExe_WithExistingRpf_ReturnsOne() StringWriter stderr = new(); Console.SetError(stderr); - int exitCode = ValidateHandler.Execute(MakeOptions(rpf, json: false)); + int exitCode = ValidateHandler.Execute(MakeOptions(rpf, json: false), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); From 1b95e7f2aecb3fd04ce7d7d16668a17d78091d7c Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:26:53 +0200 Subject: [PATCH 18/45] refactor(cli): move the handlers and their tests into directories Twelve handlers, their Json records and their tests were all in the project root, so the file list was two dozen entries deep before reaching anything structural. Handlers/ and Tests/Handlers/ mirror each other. --- CodeWalker.Cli/ExportService.cs | 1 + CodeWalker.Cli/{ => Handlers}/DiffHandler.cs | 2 +- CodeWalker.Cli/{ => Handlers}/ExportAudioHandler.cs | 2 +- CodeWalker.Cli/{ => Handlers}/ExportHandler.cs | 2 +- CodeWalker.Cli/{ => Handlers}/ExportTextHandler.cs | 2 +- CodeWalker.Cli/{ => Handlers}/ExportTexturesHandler.cs | 2 +- CodeWalker.Cli/{ => Handlers}/ExportXmlHandler.cs | 2 +- CodeWalker.Cli/{ => Handlers}/ExtractHandler.cs | 2 +- CodeWalker.Cli/{ => Handlers}/Gen9Handler.cs | 2 +- CodeWalker.Cli/{ => Handlers}/HashHandler.cs | 2 +- CodeWalker.Cli/{ => Handlers}/InspectHandler.cs | 2 +- CodeWalker.Cli/{ => Handlers}/ListHandler.cs | 2 +- CodeWalker.Cli/{ => Handlers}/PackHandler.cs | 2 +- CodeWalker.Cli/{ => Handlers}/SearchHandler.cs | 2 +- CodeWalker.Cli/{ => Handlers}/StatHandler.cs | 2 +- CodeWalker.Cli/{ => Handlers}/TreeHandler.cs | 2 +- CodeWalker.Cli/{ => Handlers}/ValidateHandler.cs | 2 +- CodeWalker.Cli/Program.cs | 2 +- CodeWalker.Cli/Tests/ExportOptionsTests.cs | 1 + CodeWalker.Cli/Tests/ExportServiceTests.cs | 2 +- CodeWalker.Cli/Tests/{ => Handlers}/DiffHandlerTests.cs | 4 ++-- CodeWalker.Cli/Tests/{ => Handlers}/ExtractHandlerTests.cs | 4 ++-- CodeWalker.Cli/Tests/{ => Handlers}/Gen9HandlerTests.cs | 4 ++-- CodeWalker.Cli/Tests/{ => Handlers}/HashHandlerTests.cs | 5 +++-- CodeWalker.Cli/Tests/{ => Handlers}/InspectHandlerTests.cs | 4 ++-- CodeWalker.Cli/Tests/{ => Handlers}/ListHandlerTests.cs | 4 ++-- CodeWalker.Cli/Tests/{ => Handlers}/PackHandlerTests.cs | 4 ++-- CodeWalker.Cli/Tests/{ => Handlers}/SearchHandlerTests.cs | 4 ++-- CodeWalker.Cli/Tests/{ => Handlers}/StatHandlerTests.cs | 4 ++-- CodeWalker.Cli/Tests/{ => Handlers}/TreeHandlerTests.cs | 4 ++-- CodeWalker.Cli/Tests/{ => Handlers}/ValidateHandlerTests.cs | 4 ++-- 31 files changed, 43 insertions(+), 40 deletions(-) rename CodeWalker.Cli/{ => Handlers}/DiffHandler.cs (99%) rename CodeWalker.Cli/{ => Handlers}/ExportAudioHandler.cs (98%) rename CodeWalker.Cli/{ => Handlers}/ExportHandler.cs (98%) rename CodeWalker.Cli/{ => Handlers}/ExportTextHandler.cs (98%) rename CodeWalker.Cli/{ => Handlers}/ExportTexturesHandler.cs (98%) rename CodeWalker.Cli/{ => Handlers}/ExportXmlHandler.cs (98%) rename CodeWalker.Cli/{ => Handlers}/ExtractHandler.cs (99%) rename CodeWalker.Cli/{ => Handlers}/Gen9Handler.cs (99%) rename CodeWalker.Cli/{ => Handlers}/HashHandler.cs (99%) rename CodeWalker.Cli/{ => Handlers}/InspectHandler.cs (99%) rename CodeWalker.Cli/{ => Handlers}/ListHandler.cs (99%) rename CodeWalker.Cli/{ => Handlers}/PackHandler.cs (99%) rename CodeWalker.Cli/{ => Handlers}/SearchHandler.cs (99%) rename CodeWalker.Cli/{ => Handlers}/StatHandler.cs (99%) rename CodeWalker.Cli/{ => Handlers}/TreeHandler.cs (99%) rename CodeWalker.Cli/{ => Handlers}/ValidateHandler.cs (99%) rename CodeWalker.Cli/Tests/{ => Handlers}/DiffHandlerTests.cs (98%) rename CodeWalker.Cli/Tests/{ => Handlers}/ExtractHandlerTests.cs (98%) rename CodeWalker.Cli/Tests/{ => Handlers}/Gen9HandlerTests.cs (98%) rename CodeWalker.Cli/Tests/{ => Handlers}/HashHandlerTests.cs (99%) rename CodeWalker.Cli/Tests/{ => Handlers}/InspectHandlerTests.cs (98%) rename CodeWalker.Cli/Tests/{ => Handlers}/ListHandlerTests.cs (98%) rename CodeWalker.Cli/Tests/{ => Handlers}/PackHandlerTests.cs (99%) rename CodeWalker.Cli/Tests/{ => Handlers}/SearchHandlerTests.cs (98%) rename CodeWalker.Cli/Tests/{ => Handlers}/StatHandlerTests.cs (98%) rename CodeWalker.Cli/Tests/{ => Handlers}/TreeHandlerTests.cs (98%) rename CodeWalker.Cli/Tests/{ => Handlers}/ValidateHandlerTests.cs (98%) diff --git a/CodeWalker.Cli/ExportService.cs b/CodeWalker.Cli/ExportService.cs index 1b9f6b7c2..71fe1c185 100644 --- a/CodeWalker.Cli/ExportService.cs +++ b/CodeWalker.Cli/ExportService.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; +using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; diff --git a/CodeWalker.Cli/DiffHandler.cs b/CodeWalker.Cli/Handlers/DiffHandler.cs similarity index 99% rename from CodeWalker.Cli/DiffHandler.cs rename to CodeWalker.Cli/Handlers/DiffHandler.cs index 03c178f5e..bc8cd4b39 100644 --- a/CodeWalker.Cli/DiffHandler.cs +++ b/CodeWalker.Cli/Handlers/DiffHandler.cs @@ -10,7 +10,7 @@ using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; -namespace CodeWalker.Cli; +namespace CodeWalker.Cli.Handlers; internal sealed record DiffOptions { diff --git a/CodeWalker.Cli/ExportAudioHandler.cs b/CodeWalker.Cli/Handlers/ExportAudioHandler.cs similarity index 98% rename from CodeWalker.Cli/ExportAudioHandler.cs rename to CodeWalker.Cli/Handlers/ExportAudioHandler.cs index 84feaeb6f..7f5fca3fe 100644 --- a/CodeWalker.Cli/ExportAudioHandler.cs +++ b/CodeWalker.Cli/Handlers/ExportAudioHandler.cs @@ -4,7 +4,7 @@ using CodeWalker.GameFiles; -namespace CodeWalker.Cli; +namespace CodeWalker.Cli.Handlers; internal static class ExportAudioHandler { diff --git a/CodeWalker.Cli/ExportHandler.cs b/CodeWalker.Cli/Handlers/ExportHandler.cs similarity index 98% rename from CodeWalker.Cli/ExportHandler.cs rename to CodeWalker.Cli/Handlers/ExportHandler.cs index e9a9d7fc2..6c7be2749 100644 --- a/CodeWalker.Cli/ExportHandler.cs +++ b/CodeWalker.Cli/Handlers/ExportHandler.cs @@ -2,7 +2,7 @@ using System.IO; using System.Threading; -namespace CodeWalker.Cli; +namespace CodeWalker.Cli.Handlers; internal sealed record ExportOptions { diff --git a/CodeWalker.Cli/ExportTextHandler.cs b/CodeWalker.Cli/Handlers/ExportTextHandler.cs similarity index 98% rename from CodeWalker.Cli/ExportTextHandler.cs rename to CodeWalker.Cli/Handlers/ExportTextHandler.cs index 604e3a589..a2abb8902 100644 --- a/CodeWalker.Cli/ExportTextHandler.cs +++ b/CodeWalker.Cli/Handlers/ExportTextHandler.cs @@ -5,7 +5,7 @@ using CodeWalker.GameFiles; -namespace CodeWalker.Cli; +namespace CodeWalker.Cli.Handlers; internal static class ExportTextHandler { diff --git a/CodeWalker.Cli/ExportTexturesHandler.cs b/CodeWalker.Cli/Handlers/ExportTexturesHandler.cs similarity index 98% rename from CodeWalker.Cli/ExportTexturesHandler.cs rename to CodeWalker.Cli/Handlers/ExportTexturesHandler.cs index 7f60bc8fc..607594186 100644 --- a/CodeWalker.Cli/ExportTexturesHandler.cs +++ b/CodeWalker.Cli/Handlers/ExportTexturesHandler.cs @@ -5,7 +5,7 @@ using CodeWalker.GameFiles; using CodeWalker.Utils; -namespace CodeWalker.Cli; +namespace CodeWalker.Cli.Handlers; internal static class ExportTexturesHandler { diff --git a/CodeWalker.Cli/ExportXmlHandler.cs b/CodeWalker.Cli/Handlers/ExportXmlHandler.cs similarity index 98% rename from CodeWalker.Cli/ExportXmlHandler.cs rename to CodeWalker.Cli/Handlers/ExportXmlHandler.cs index a332a8648..6e6995325 100644 --- a/CodeWalker.Cli/ExportXmlHandler.cs +++ b/CodeWalker.Cli/Handlers/ExportXmlHandler.cs @@ -5,7 +5,7 @@ using CodeWalker.GameFiles; -namespace CodeWalker.Cli; +namespace CodeWalker.Cli.Handlers; internal static class ExportXmlHandler { diff --git a/CodeWalker.Cli/ExtractHandler.cs b/CodeWalker.Cli/Handlers/ExtractHandler.cs similarity index 99% rename from CodeWalker.Cli/ExtractHandler.cs rename to CodeWalker.Cli/Handlers/ExtractHandler.cs index 36f5d1fcd..dffd7581e 100644 --- a/CodeWalker.Cli/ExtractHandler.cs +++ b/CodeWalker.Cli/Handlers/ExtractHandler.cs @@ -9,7 +9,7 @@ using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; -namespace CodeWalker.Cli; +namespace CodeWalker.Cli.Handlers; internal sealed record ExtractOptions { diff --git a/CodeWalker.Cli/Gen9Handler.cs b/CodeWalker.Cli/Handlers/Gen9Handler.cs similarity index 99% rename from CodeWalker.Cli/Gen9Handler.cs rename to CodeWalker.Cli/Handlers/Gen9Handler.cs index ce91fd1df..a2a3c8650 100644 --- a/CodeWalker.Cli/Gen9Handler.cs +++ b/CodeWalker.Cli/Handlers/Gen9Handler.cs @@ -11,7 +11,7 @@ using CodeWalker.Core.Utils; using CodeWalker.GameFiles; -namespace CodeWalker.Cli; +namespace CodeWalker.Cli.Handlers; internal sealed record Gen9Options { diff --git a/CodeWalker.Cli/HashHandler.cs b/CodeWalker.Cli/Handlers/HashHandler.cs similarity index 99% rename from CodeWalker.Cli/HashHandler.cs rename to CodeWalker.Cli/Handlers/HashHandler.cs index 1155e002f..2b841fe85 100644 --- a/CodeWalker.Cli/HashHandler.cs +++ b/CodeWalker.Cli/Handlers/HashHandler.cs @@ -6,7 +6,7 @@ using CodeWalker.GameFiles; -namespace CodeWalker.Cli; +namespace CodeWalker.Cli.Handlers; internal sealed record HashOptions { diff --git a/CodeWalker.Cli/InspectHandler.cs b/CodeWalker.Cli/Handlers/InspectHandler.cs similarity index 99% rename from CodeWalker.Cli/InspectHandler.cs rename to CodeWalker.Cli/Handlers/InspectHandler.cs index 531ce7bdd..ef0bd19c6 100644 --- a/CodeWalker.Cli/InspectHandler.cs +++ b/CodeWalker.Cli/Handlers/InspectHandler.cs @@ -11,7 +11,7 @@ using SharpDX; -namespace CodeWalker.Cli; +namespace CodeWalker.Cli.Handlers; internal static class InspectHandler { diff --git a/CodeWalker.Cli/ListHandler.cs b/CodeWalker.Cli/Handlers/ListHandler.cs similarity index 99% rename from CodeWalker.Cli/ListHandler.cs rename to CodeWalker.Cli/Handlers/ListHandler.cs index 396d1df0e..cdc588dd6 100644 --- a/CodeWalker.Cli/ListHandler.cs +++ b/CodeWalker.Cli/Handlers/ListHandler.cs @@ -8,7 +8,7 @@ using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; -namespace CodeWalker.Cli; +namespace CodeWalker.Cli.Handlers; internal static class ListHandler { diff --git a/CodeWalker.Cli/PackHandler.cs b/CodeWalker.Cli/Handlers/PackHandler.cs similarity index 99% rename from CodeWalker.Cli/PackHandler.cs rename to CodeWalker.Cli/Handlers/PackHandler.cs index 2fbf82e97..6aded0ac9 100644 --- a/CodeWalker.Cli/PackHandler.cs +++ b/CodeWalker.Cli/Handlers/PackHandler.cs @@ -8,7 +8,7 @@ using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; -namespace CodeWalker.Cli; +namespace CodeWalker.Cli.Handlers; internal sealed record PackOptions { diff --git a/CodeWalker.Cli/SearchHandler.cs b/CodeWalker.Cli/Handlers/SearchHandler.cs similarity index 99% rename from CodeWalker.Cli/SearchHandler.cs rename to CodeWalker.Cli/Handlers/SearchHandler.cs index 0b003e639..414370a27 100644 --- a/CodeWalker.Cli/SearchHandler.cs +++ b/CodeWalker.Cli/Handlers/SearchHandler.cs @@ -10,7 +10,7 @@ using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; -namespace CodeWalker.Cli; +namespace CodeWalker.Cli.Handlers; internal static class SearchHandler { diff --git a/CodeWalker.Cli/StatHandler.cs b/CodeWalker.Cli/Handlers/StatHandler.cs similarity index 99% rename from CodeWalker.Cli/StatHandler.cs rename to CodeWalker.Cli/Handlers/StatHandler.cs index 127362994..c13586f0f 100644 --- a/CodeWalker.Cli/StatHandler.cs +++ b/CodeWalker.Cli/Handlers/StatHandler.cs @@ -9,7 +9,7 @@ using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; -namespace CodeWalker.Cli; +namespace CodeWalker.Cli.Handlers; internal static class StatHandler { diff --git a/CodeWalker.Cli/TreeHandler.cs b/CodeWalker.Cli/Handlers/TreeHandler.cs similarity index 99% rename from CodeWalker.Cli/TreeHandler.cs rename to CodeWalker.Cli/Handlers/TreeHandler.cs index 486a1ce3b..f6878e7f2 100644 --- a/CodeWalker.Cli/TreeHandler.cs +++ b/CodeWalker.Cli/Handlers/TreeHandler.cs @@ -9,7 +9,7 @@ using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; -namespace CodeWalker.Cli; +namespace CodeWalker.Cli.Handlers; internal sealed record TreeOptions { diff --git a/CodeWalker.Cli/ValidateHandler.cs b/CodeWalker.Cli/Handlers/ValidateHandler.cs similarity index 99% rename from CodeWalker.Cli/ValidateHandler.cs rename to CodeWalker.Cli/Handlers/ValidateHandler.cs index 80ca7ae06..191f16d1f 100644 --- a/CodeWalker.Cli/ValidateHandler.cs +++ b/CodeWalker.Cli/Handlers/ValidateHandler.cs @@ -10,7 +10,7 @@ using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; -namespace CodeWalker.Cli; +namespace CodeWalker.Cli.Handlers; internal sealed record ValidateOptions { diff --git a/CodeWalker.Cli/Program.cs b/CodeWalker.Cli/Program.cs index 43e221181..171e345d4 100644 --- a/CodeWalker.Cli/Program.cs +++ b/CodeWalker.Cli/Program.cs @@ -2,7 +2,7 @@ using System.CommandLine; using System.Threading; -using CodeWalker.Cli; +using CodeWalker.Cli.Handlers; using CancellationTokenSource cts = new(); Console.CancelKeyPress += (_, e) => diff --git a/CodeWalker.Cli/Tests/ExportOptionsTests.cs b/CodeWalker.Cli/Tests/ExportOptionsTests.cs index 6ac8f35cb..daaba3b29 100644 --- a/CodeWalker.Cli/Tests/ExportOptionsTests.cs +++ b/CodeWalker.Cli/Tests/ExportOptionsTests.cs @@ -1,5 +1,6 @@ using System.CommandLine; +using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; using Xunit; diff --git a/CodeWalker.Cli/Tests/ExportServiceTests.cs b/CodeWalker.Cli/Tests/ExportServiceTests.cs index fc2bb5cc6..f2280bd10 100644 --- a/CodeWalker.Cli/Tests/ExportServiceTests.cs +++ b/CodeWalker.Cli/Tests/ExportServiceTests.cs @@ -1,11 +1,11 @@ using System; using System.IO; +using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; using Xunit; -using Xunit.v3; namespace CodeWalker.Cli.Tests; diff --git a/CodeWalker.Cli/Tests/DiffHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/DiffHandlerTests.cs similarity index 98% rename from CodeWalker.Cli/Tests/DiffHandlerTests.cs rename to CodeWalker.Cli/Tests/Handlers/DiffHandlerTests.cs index c1a8ac62e..c951568e7 100644 --- a/CodeWalker.Cli/Tests/DiffHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/DiffHandlerTests.cs @@ -1,12 +1,12 @@ using System; using System.IO; +using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; using Xunit; -using Xunit.v3; -namespace CodeWalker.Cli.Tests; +namespace CodeWalker.Cli.Tests.Handlers; public sealed class DiffHandlerTests { diff --git a/CodeWalker.Cli/Tests/ExtractHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/ExtractHandlerTests.cs similarity index 98% rename from CodeWalker.Cli/Tests/ExtractHandlerTests.cs rename to CodeWalker.Cli/Tests/Handlers/ExtractHandlerTests.cs index 3f6935957..e3254f472 100644 --- a/CodeWalker.Cli/Tests/ExtractHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/ExtractHandlerTests.cs @@ -1,12 +1,12 @@ using System; using System.IO; +using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; using Xunit; -using Xunit.v3; -namespace CodeWalker.Cli.Tests; +namespace CodeWalker.Cli.Tests.Handlers; [Collection("ConsoleOutput")] public sealed class ExtractHandlerTests diff --git a/CodeWalker.Cli/Tests/Gen9HandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/Gen9HandlerTests.cs similarity index 98% rename from CodeWalker.Cli/Tests/Gen9HandlerTests.cs rename to CodeWalker.Cli/Tests/Handlers/Gen9HandlerTests.cs index 442cdf03f..7028c81cc 100644 --- a/CodeWalker.Cli/Tests/Gen9HandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/Gen9HandlerTests.cs @@ -1,12 +1,12 @@ using System; using System.IO; +using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; using Xunit; -using Xunit.v3; -namespace CodeWalker.Cli.Tests; +namespace CodeWalker.Cli.Tests.Handlers; [Collection("ConsoleOutput")] public sealed class Gen9HandlerTests diff --git a/CodeWalker.Cli/Tests/HashHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs similarity index 99% rename from CodeWalker.Cli/Tests/HashHandlerTests.cs rename to CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs index f869413bb..129be77be 100644 --- a/CodeWalker.Cli/Tests/HashHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs @@ -1,10 +1,11 @@ using System; using System.IO; +using CodeWalker.Cli.Handlers; + using Xunit; -using Xunit.v3; -namespace CodeWalker.Cli.Tests; +namespace CodeWalker.Cli.Tests.Handlers; [Collection("ConsoleOutput")] public sealed class HashHandlerTests diff --git a/CodeWalker.Cli/Tests/InspectHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/InspectHandlerTests.cs similarity index 98% rename from CodeWalker.Cli/Tests/InspectHandlerTests.cs rename to CodeWalker.Cli/Tests/Handlers/InspectHandlerTests.cs index 67d754597..8cc4748f5 100644 --- a/CodeWalker.Cli/Tests/InspectHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/InspectHandlerTests.cs @@ -1,14 +1,14 @@ using System; using System.IO; +using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; using SharpDX; using Xunit; -using Xunit.v3; -namespace CodeWalker.Cli.Tests; +namespace CodeWalker.Cli.Tests.Handlers; public sealed class InspectHandlerTests { diff --git a/CodeWalker.Cli/Tests/ListHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/ListHandlerTests.cs similarity index 98% rename from CodeWalker.Cli/Tests/ListHandlerTests.cs rename to CodeWalker.Cli/Tests/Handlers/ListHandlerTests.cs index dc3ecaaa1..a6764a158 100644 --- a/CodeWalker.Cli/Tests/ListHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/ListHandlerTests.cs @@ -1,12 +1,12 @@ using System; using System.IO; +using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; using Xunit; -using Xunit.v3; -namespace CodeWalker.Cli.Tests; +namespace CodeWalker.Cli.Tests.Handlers; [Collection("ConsoleOutput")] public sealed class ListHandlerTests diff --git a/CodeWalker.Cli/Tests/PackHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/PackHandlerTests.cs similarity index 99% rename from CodeWalker.Cli/Tests/PackHandlerTests.cs rename to CodeWalker.Cli/Tests/Handlers/PackHandlerTests.cs index e3c32a593..dd4ee2661 100644 --- a/CodeWalker.Cli/Tests/PackHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/PackHandlerTests.cs @@ -1,12 +1,12 @@ using System; using System.IO; +using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; using Xunit; -using Xunit.v3; -namespace CodeWalker.Cli.Tests; +namespace CodeWalker.Cli.Tests.Handlers; [Collection("ConsoleOutput")] public sealed class PackHandlerTests diff --git a/CodeWalker.Cli/Tests/SearchHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs similarity index 98% rename from CodeWalker.Cli/Tests/SearchHandlerTests.cs rename to CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs index c6d5a929d..f93dcbcd4 100644 --- a/CodeWalker.Cli/Tests/SearchHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs @@ -1,12 +1,12 @@ using System; using System.IO; +using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; using Xunit; -using Xunit.v3; -namespace CodeWalker.Cli.Tests; +namespace CodeWalker.Cli.Tests.Handlers; public sealed class SearchHandlerTests { diff --git a/CodeWalker.Cli/Tests/StatHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs similarity index 98% rename from CodeWalker.Cli/Tests/StatHandlerTests.cs rename to CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs index 5e39db953..f0221c3bc 100644 --- a/CodeWalker.Cli/Tests/StatHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs @@ -1,12 +1,12 @@ using System; using System.IO; +using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; using Xunit; -using Xunit.v3; -namespace CodeWalker.Cli.Tests; +namespace CodeWalker.Cli.Tests.Handlers; [Collection("ConsoleOutput")] public sealed class StatHandlerTests diff --git a/CodeWalker.Cli/Tests/TreeHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/TreeHandlerTests.cs similarity index 98% rename from CodeWalker.Cli/Tests/TreeHandlerTests.cs rename to CodeWalker.Cli/Tests/Handlers/TreeHandlerTests.cs index c46d91a9b..ffadd7463 100644 --- a/CodeWalker.Cli/Tests/TreeHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/TreeHandlerTests.cs @@ -1,12 +1,12 @@ using System; using System.IO; +using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; using Xunit; -using Xunit.v3; -namespace CodeWalker.Cli.Tests; +namespace CodeWalker.Cli.Tests.Handlers; [Collection("ConsoleOutput")] public sealed class TreeHandlerTests diff --git a/CodeWalker.Cli/Tests/ValidateHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/ValidateHandlerTests.cs similarity index 98% rename from CodeWalker.Cli/Tests/ValidateHandlerTests.cs rename to CodeWalker.Cli/Tests/Handlers/ValidateHandlerTests.cs index 2e9951a0c..62591ddd2 100644 --- a/CodeWalker.Cli/Tests/ValidateHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/ValidateHandlerTests.cs @@ -1,12 +1,12 @@ using System; using System.IO; +using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; using Xunit; -using Xunit.v3; -namespace CodeWalker.Cli.Tests; +namespace CodeWalker.Cli.Tests.Handlers; [Collection("ConsoleOutput")] public sealed class ValidateHandlerTests From 3c3f82ef1673bcc65a5370a32aec105492272dd1 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:26:53 +0200 Subject: [PATCH 19/45] chore(cli): move WarningsAsErrors into Directory.Build.props Both projects in this directory declared the same WarningsAsErrors property. Directory.Build.props already holds everything else they share. --- CodeWalker.Cli/CodeWalker.Cli.Tests.csproj | 4 ---- CodeWalker.Cli/CodeWalker.Cli.csproj | 4 ---- CodeWalker.Cli/Directory.Build.props | 4 ++++ 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/CodeWalker.Cli/CodeWalker.Cli.Tests.csproj b/CodeWalker.Cli/CodeWalker.Cli.Tests.csproj index a84d9a945..f18c1d96a 100644 --- a/CodeWalker.Cli/CodeWalker.Cli.Tests.csproj +++ b/CodeWalker.Cli/CodeWalker.Cli.Tests.csproj @@ -31,8 +31,4 @@ - - $(WarningsAsErrors);CS8509 - - diff --git a/CodeWalker.Cli/CodeWalker.Cli.csproj b/CodeWalker.Cli/CodeWalker.Cli.csproj index 0dc5bd6da..f9f30948b 100644 --- a/CodeWalker.Cli/CodeWalker.Cli.csproj +++ b/CodeWalker.Cli/CodeWalker.Cli.csproj @@ -30,8 +30,4 @@ true - - $(WarningsAsErrors);CS8509 - - diff --git a/CodeWalker.Cli/Directory.Build.props b/CodeWalker.Cli/Directory.Build.props index fbc076043..9e1edb187 100644 --- a/CodeWalker.Cli/Directory.Build.props +++ b/CodeWalker.Cli/Directory.Build.props @@ -37,4 +37,8 @@ + + $(WarningsAsErrors);CS8509 + + From 86aacf0b5c6609f31d411738d93429beceb7b735 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:06:50 +0200 Subject: [PATCH 20/45] refactor(cli): rework the shared helpers and cover them with tests ProgressBar wrote the bar, the stats and the padding as separate calls, so a redraw could interleave with another thread's output and a long file name could wrap the line. The line is built as one string now, clamped to the terminal width, and written once. Throttling moved from DateTime.Now to a Stopwatch, which does not jump when the system clock does. Update is private, Increment is the only way in and it stops at the total, and Dispose is idempotent and takes the same lock as the render. CollectFiles and the non-RPF count expressed the same predicate two different ways; they share one now. The export aggregation counted a file as both errored and skipped, and took its exit code from the error count rather than from the messages it was about to print, so a scan error could be reported without changing the exit code. The ThrowIfCancellationRequested at the top of each Parallel.For body is redundant: ParallelOptions.CancellationToken is already checked before every iteration. Tests cover ProgressBar, SizeFormat, Filter and both services. --- CodeWalker.Cli/ExportService.cs | 12 +- CodeWalker.Cli/Handlers/DiffHandler.cs | 1 - CodeWalker.Cli/Handlers/ExtractHandler.cs | 1 - CodeWalker.Cli/Handlers/Gen9Handler.cs | 1 - CodeWalker.Cli/Handlers/SearchHandler.cs | 1 - CodeWalker.Cli/Handlers/ValidateHandler.cs | 1 - CodeWalker.Cli/Helpers/Filter.cs | 2 + CodeWalker.Cli/Helpers/ProgressBar.cs | 146 +++++++------ CodeWalker.Cli/Helpers/SizeFormat.cs | 3 + CodeWalker.Cli/RpfService.cs | 14 +- CodeWalker.Cli/Tests/ExportServiceTests.cs | 131 +++++++++++- CodeWalker.Cli/Tests/Helpers/FilterTests.cs | 163 +++++++------- .../Tests/Helpers/ProgressBarTests.cs | 192 +++++++++++------ .../Tests/Helpers/SizeFormatTests.cs | 200 ++++++++++++------ CodeWalker.Cli/Tests/RpfServiceTests.cs | 62 +++++- 15 files changed, 640 insertions(+), 290 deletions(-) diff --git a/CodeWalker.Cli/ExportService.cs b/CodeWalker.Cli/ExportService.cs index 71fe1c185..423878a64 100644 --- a/CodeWalker.Cli/ExportService.cs +++ b/CodeWalker.Cli/ExportService.cs @@ -110,7 +110,7 @@ int filterSkipped if (errorMessage == null && jsonEntry?.Status is "exported" or "dry_run") exported++; - if (jsonEntry?.Status is "unsupported" or "skipped") + if (errorMessage == null && jsonEntry?.Status is "unsupported" or "skipped") skipped++; if (jsonEntry != null) @@ -121,6 +121,11 @@ int filterSkipped errors++; errorMessages.Add(errorMessage); } + else if (jsonEntry?.Status == "error") + { + errors++; + errorMessages.Add($"Error processing: {jsonEntry.Path}"); + } } skipped += filterSkipped; @@ -219,7 +224,6 @@ Json.ExportResult ErrorResult(string[] errorMessages) => new ParallelOptions { MaxDegreeOfParallelism = options.Rpf.Threads, CancellationToken = cancellationToken }, i => { - cancellationToken.ThrowIfCancellationRequested(); (RpfFile sourceRpf, RpfFileEntry fileEntry) = filesToExport[i]; try { @@ -292,7 +296,7 @@ Json.ExportResult ErrorResult(string[] errorMessages) => Json.ExportResult jsonResult = new() { - Success = agg.Errors == 0 && scanErrors.Count == 0, + Success = agg.ErrorMessages.Count == 0, RpfFile = options.Rpf.RpfPath, OutputDir = options.OutputPath, Format = format, @@ -320,7 +324,7 @@ Json.ExportResult ErrorResult(string[] errorMessages) => ); } - return (agg.Errors > 0 || scanErrors.Count > 0) ? 1 : 0; + return agg.ErrorMessages.Count > 0 ? 1 : 0; } catch (OperationCanceledException) { throw; } catch (Exception ex) diff --git a/CodeWalker.Cli/Handlers/DiffHandler.cs b/CodeWalker.Cli/Handlers/DiffHandler.cs index bc8cd4b39..d3b361197 100644 --- a/CodeWalker.Cli/Handlers/DiffHandler.cs +++ b/CodeWalker.Cli/Handlers/DiffHandler.cs @@ -172,7 +172,6 @@ Json.DiffResult ErrorResult(string[] errorMessages) => new ParallelOptions { MaxDegreeOfParallelism = options.Common.Threads, CancellationToken = cancellationToken }, i => { - cancellationToken.ThrowIfCancellationRequested(); string path = commonPaths[i]; (RpfFile leftRpfRef, RpfFileEntry leftEntry) = leftDict[path]; (RpfFile rightRpfRef, RpfFileEntry rightEntry) = rightDict[path]; diff --git a/CodeWalker.Cli/Handlers/ExtractHandler.cs b/CodeWalker.Cli/Handlers/ExtractHandler.cs index dffd7581e..0c1259dbf 100644 --- a/CodeWalker.Cli/Handlers/ExtractHandler.cs +++ b/CodeWalker.Cli/Handlers/ExtractHandler.cs @@ -153,7 +153,6 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => new ParallelOptions { MaxDegreeOfParallelism = options.Rpf.Threads, CancellationToken = cancellationToken }, i => { - cancellationToken.ThrowIfCancellationRequested(); (RpfFile sourceRpf, RpfFileEntry fileEntry) = filesToExtract[i]; try { diff --git a/CodeWalker.Cli/Handlers/Gen9Handler.cs b/CodeWalker.Cli/Handlers/Gen9Handler.cs index a2a3c8650..55ed532c9 100644 --- a/CodeWalker.Cli/Handlers/Gen9Handler.cs +++ b/CodeWalker.Cli/Handlers/Gen9Handler.cs @@ -207,7 +207,6 @@ Json.Gen9Result ErrorResult(string[] errorMessages) => new ParallelOptions { MaxDegreeOfParallelism = options.Common.Threads, CancellationToken = cancellationToken }, i => { - cancellationToken.ThrowIfCancellationRequested(); string path = filePaths[i]; string relPath = path[inputFolder.Length..]; string outPath = Path.Combine(options.OutputPath, relPath); diff --git a/CodeWalker.Cli/Handlers/SearchHandler.cs b/CodeWalker.Cli/Handlers/SearchHandler.cs index 414370a27..77c658fa8 100644 --- a/CodeWalker.Cli/Handlers/SearchHandler.cs +++ b/CodeWalker.Cli/Handlers/SearchHandler.cs @@ -140,7 +140,6 @@ out uint hash new ParallelOptions { MaxDegreeOfParallelism = options.Threads, CancellationToken = cancellationToken }, i => { - cancellationToken.ThrowIfCancellationRequested(); RpfEntry entry = allEntries[i]; if (!matcher(entry)) return; diff --git a/CodeWalker.Cli/Handlers/ValidateHandler.cs b/CodeWalker.Cli/Handlers/ValidateHandler.cs index 191f16d1f..36dc2560d 100644 --- a/CodeWalker.Cli/Handlers/ValidateHandler.cs +++ b/CodeWalker.Cli/Handlers/ValidateHandler.cs @@ -107,7 +107,6 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => new ParallelOptions { MaxDegreeOfParallelism = options.Rpf.Threads, CancellationToken = cancellationToken }, i => { - cancellationToken.ThrowIfCancellationRequested(); (_, RpfFileEntry fileEntry) = entries[i]; string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); diff --git a/CodeWalker.Cli/Helpers/Filter.cs b/CodeWalker.Cli/Helpers/Filter.cs index c82de4eea..1b3fc837d 100644 --- a/CodeWalker.Cli/Helpers/Filter.cs +++ b/CodeWalker.Cli/Helpers/Filter.cs @@ -15,6 +15,8 @@ internal static class Filter /// /// Normalizes filter patterns once at parse time: trims, lowercases, and strips blanks. /// + /// Array of filter patterns to normalize. + /// Normalized array of filter patterns. public static string[] Normalize(string[]? filters) { if (filters == null || filters.Length == 0) diff --git a/CodeWalker.Cli/Helpers/ProgressBar.cs b/CodeWalker.Cli/Helpers/ProgressBar.cs index 6efb61b5d..8717248e1 100644 --- a/CodeWalker.Cli/Helpers/ProgressBar.cs +++ b/CodeWalker.Cli/Helpers/ProgressBar.cs @@ -1,4 +1,6 @@ using System; +using System.Diagnostics; +using System.Globalization; using System.IO; using System.Security; @@ -9,15 +11,41 @@ namespace CodeWalker.Cli.Helpers; /// internal sealed class ProgressBar : IDisposable { + /// Default writer when no custom writer is provided. Uses stderr to allow console control. + private static TextWriter DefaultWriter => Console.Error; + /// Detect if the default console writer is redirected, in which case we disable the progress bar to avoid writing control characters to the output. + private static bool IsDefaultWriterRedirected => Console.IsErrorRedirected; + + /// Minimum milliseconds between render updates to prevent flickering. + private const int ThrottleMs = 50; + /// Character width of the [===> ] bar portion. + private const int BarWidth = 40; + + /// Total number of items to process. private readonly int _total; - private int _current; - private readonly bool _enabled; - private readonly int _barWidth = 40; + /// Output destination (stderr or a caller-supplied writer). private readonly TextWriter _writer; + /// Whether this instance owns the console (true when no custom writer was provided). private readonly bool _ownsConsole; + /// Terminal width used for padding and line clearing. private readonly int _windowWidth; - private DateTime _lastUpdate = DateTime.MinValue; + /// Monotonic timer for throttling render updates. + private readonly Stopwatch _throttle = new(); + /// Guards all mutable state for thread-safe updates. private readonly object _lock = new(); + /// Tracks whether has been called. + private bool _disposed; + + internal int Current { get; private set; } + internal bool Enabled { get; } + + internal void ResetThrottle() + { + lock (this._lock) + { + this._throttle.Reset(); + } + } /// /// Initializes a new instance of the ProgressBar class. @@ -29,11 +57,11 @@ internal sealed class ProgressBar : IDisposable public ProgressBar(int total, bool enabled, TextWriter? writer = null, int windowWidth = 120) { this._total = total; - this._writer = writer ?? Console.Error; - this._ownsConsole = writer is null; + this._writer = writer ?? DefaultWriter; + this._ownsConsole = this._writer == Console.Error || this._writer == Console.Out; this._windowWidth = windowWidth; - this._enabled = enabled && total > 0 && (!this._ownsConsole || !Console.IsErrorRedirected); - if (this._enabled) + this.Enabled = enabled && total > 0 && (!this._ownsConsole || !IsDefaultWriterRedirected); + if (this.Enabled) { if (this._ownsConsole) { @@ -44,6 +72,7 @@ public ProgressBar(int total, bool enabled, TextWriter? writer = null, int windo catch { } } this.Render(); + this._throttle.Start(); } } @@ -52,89 +81,82 @@ public ProgressBar(int total, bool enabled, TextWriter? writer = null, int windo /// /// Current number of items processed. /// Optional current file being processed. - public void Update(int current, string? currentFile = null) + /// + /// Must be called under _lock to ensure thread safety with Increment and Dispose. + /// + private void Update(int current, string? currentFile = null) { - lock (this._lock) - { - this._current = current; - if (!this._enabled) - return; + this.Current = Math.Max(0, Math.Min(current, this._total)); + if (!this.Enabled) + return; - // Throttle updates to avoid flickering - if ((DateTime.Now - this._lastUpdate).TotalMilliseconds < 50 && current < this._total) - return; + // Throttle updates to avoid flickering + if (this._throttle.IsRunning && this._throttle.ElapsedMilliseconds < ThrottleMs && current < this._total) + return; - this._lastUpdate = DateTime.Now; - this.Render(currentFile); - } + this._throttle.Restart(); + this.Render(currentFile); } /// /// Increments the progress bar by one. - /// Thread-safe: the increment and render happen atomically under a lock. /// /// Optional current file being processed. + /// + /// Thread-safe: the increment and render happen atomically under a lock. + /// public void Increment(string? currentFile = null) { lock (this._lock) { - this._current++; - if (!this._enabled) - return; - - if ((DateTime.Now - this._lastUpdate).TotalMilliseconds < 50 && this._current < this._total) - return; - - this._lastUpdate = DateTime.Now; - this.Render(currentFile); + if (this.Current >= this._total) return; + this.Update(this.Current + 1, currentFile); } } + /// + /// Writes the progress bar line to , overwriting the current console line. + /// + /// Optional filename appended after the percentage stats. private void Render(string? currentFile = null) { - if (!this._enabled) + if (!this.Enabled || this._disposed) return; try { - double percent = this._total > 0 ? (double)this._current / this._total : 0; - int filled = Math.Min((int)(percent * this._barWidth), this._barWidth); + double percent = this._total > 0 ? (double)this.Current / this._total : 0; + int filled = Math.Min((int)(percent * BarWidth), BarWidth); int winWidth = this._ownsConsole ? Console.WindowWidth : this._windowWidth; + int maxWidth = Math.Max(1, winWidth - 1); - if (this._ownsConsole) - Console.SetCursorPosition(0, Console.CursorTop); - - this._writer.Write("["); - this._writer.Write(new string('=', filled)); - if (filled < this._barWidth) - { - this._writer.Write(">"); - this._writer.Write(new string(' ', this._barWidth - filled - 1)); - } + // Build the full line as a single string: [====> ] 100 % (50/100) file.ytd + string line = filled < BarWidth + ? $"[{new string('=', filled)}>{new string(' ', BarWidth - filled - 1)}" + : $"[{new string('=', filled)}"; - string stats = $"] {percent,6:P0} ({this._current}/{this._total})"; - this._writer.Write(stats); - - int written = 1 + this._barWidth + stats.Length; + line += string.Format(CultureInfo.InvariantCulture, "] {0,6:P0} ({1}/{2})", percent, this.Current, this._total); if (!string.IsNullOrEmpty(currentFile)) { - int maxLen = Math.Max(10, winWidth - this._barWidth - 30); + int maxLen = Math.Max(10, winWidth - BarWidth - 30); string displayFile = currentFile!.Length > maxLen ? $"...{currentFile[(currentFile.Length - maxLen + 3)..]}" : currentFile; - string fileText = $" {displayFile}"; - this._writer.Write(fileText); - written += fileText.Length; + line += $" {displayFile}"; } - // Clear rest of line - int remaining = winWidth - written - 1; - if (remaining > 0) - { - this._writer.Write(new string(' ', remaining)); - } + // Clamp to terminal width to prevent wrapping; pad remainder to overwrite stale characters + if (line.Length > maxWidth) + line = line[..maxWidth]; + else if (line.Length < maxWidth) + line += new string(' ', maxWidth - line.Length); + + if (this._ownsConsole) + Console.SetCursorPosition(0, Console.CursorTop); + + this._writer.Write(line); } catch (Exception ex) when (ex is IOException or InvalidOperationException or SecurityException) @@ -148,8 +170,12 @@ private void Render(string? currentFile = null) /// public void Dispose() { - if (this._enabled) + lock (this._lock) { + if (this._disposed || !this.Enabled) + return; + this._disposed = true; + try { this._writer.WriteLine(); @@ -158,7 +184,9 @@ public void Dispose() } catch (Exception ex) when (ex is IOException or InvalidOperationException or SecurityException) - { } + { + // Ignore console errors during dispose + } } } } diff --git a/CodeWalker.Cli/Helpers/SizeFormat.cs b/CodeWalker.Cli/Helpers/SizeFormat.cs index c1e2df33c..26899ff99 100644 --- a/CodeWalker.Cli/Helpers/SizeFormat.cs +++ b/CodeWalker.Cli/Helpers/SizeFormat.cs @@ -41,6 +41,9 @@ private static string[] GetSuffixes(this SizeFormat format) => /// /// Formats the given byte size into a human-readable string based on the size format. /// + /// The size format to use. + /// The size in bytes to format. + /// A human-readable string representation of the byte size. public static string ToFormattedString(this SizeFormat format, long bytes) { double divisor = format.GetDivisor(); diff --git a/CodeWalker.Cli/RpfService.cs b/CodeWalker.Cli/RpfService.cs index 58f87560c..e577cacae 100644 --- a/CodeWalker.Cli/RpfService.cs +++ b/CodeWalker.Cli/RpfService.cs @@ -103,7 +103,8 @@ private static void CollectFilesRecursive( files.AddRange( rpf.AllEntries .OfType() - .Where(fe => !fe.NameLower.EndsWith(".rpf", StringComparison.Ordinal) + .Where(fe => + !fe.NameLower.EndsWith(".rpf", StringComparison.Ordinal) && Filter.Matches(fe.Path, filters)) .Select(fe => (rpf, fe)) ); @@ -132,13 +133,10 @@ private static void CountNonRpfFilesRecursive(RpfFile rpf, bool recursive, ref i { if (rpf.AllEntries != null) { - foreach (RpfEntry entry in rpf.AllEntries) - { - if (entry is RpfFileEntry && !entry.NameLower.EndsWith(".rpf", StringComparison.Ordinal)) - { - count++; - } - } + count += rpf.AllEntries + .Count(entry => + entry is RpfFileEntry + && !entry.NameLower.EndsWith(".rpf", StringComparison.Ordinal)); } if (recursive && rpf.Children != null) diff --git a/CodeWalker.Cli/Tests/ExportServiceTests.cs b/CodeWalker.Cli/Tests/ExportServiceTests.cs index f2280bd10..ffbd7ba78 100644 --- a/CodeWalker.Cli/Tests/ExportServiceTests.cs +++ b/CodeWalker.Cli/Tests/ExportServiceTests.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Threading; using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; @@ -79,6 +80,27 @@ public void Execute_ReturnsOne_WhenValidationFails_JsonMode() } } + [Fact] + public void Execute_WithCancelledToken_StillReturnsValidationError() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + StringWriter stderr = new(); + Console.SetError(stderr); + int exitCode = ExportService.Execute(MakeOptions(json: false), "xml", "XML", NoOpProcessor, new CancellationToken(canceled: true)); + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + [Fact] public void Execute_JsonError_ContainsExpectedFields() { @@ -98,6 +120,7 @@ public void Execute_JsonError_ContainsExpectedFields() } } +[Collection("ConsoleOutput")] public sealed class ProcessSingleFileTests { private static RpfBinaryFileEntry MakeEntry(string path, string name) => @@ -246,6 +269,23 @@ public void ProcessorReturnsUnsupported_ReturnsSuccess() Assert.Null(error); } + [Fact] + public void ProcessorThrows_ExceptionPropagates() + { + InvalidOperationException ex = Assert.Throws(() => + ExportService.ProcessSingleFile( + MakeEntry("test.ydr", "test.ydr"), + data: [1], + outputDir: "/out", + dryRun: false, + noOverwrite: false, + processor: (_, _, _, _) => throw new InvalidOperationException("processor crashed") + ) + ); + + Assert.Equal("processor crashed", ex.Message); + } + [Fact] public void OutputDirectory_ComputedFromBackslashPath() { @@ -279,6 +319,7 @@ public void OutputDirectory_ComputedFromBackslashPath() } } +[Collection("ConsoleOutput")] public sealed class AggregateResultsTests { private static readonly string[] OneScanError = ["scan error 1"]; @@ -297,7 +338,7 @@ private static Json.ExportFileEntry MakeFileEntry(string status) => public void EmptyResults_AllZeros_OnlyScanErrors() { ExportService.ExportAggregation agg = ExportService.AggregateResults( - Array.Empty<(Json.ExportFileEntry?, string?)>(), + [], OneScanError, filterSkipped: 0 ); @@ -320,7 +361,7 @@ public void CountsExportedAndDryRun_AsExported() (MakeFileEntry("exported"), null), ]; - ExportService.ExportAggregation agg = ExportService.AggregateResults(results, Array.Empty(), filterSkipped: 0); + ExportService.ExportAggregation agg = ExportService.AggregateResults(results, [], filterSkipped: 0); Assert.Equal(3, agg.Exported); } @@ -334,7 +375,7 @@ public void CountsUnsupportedAndSkipped_AsSkipped() (MakeFileEntry("skipped"), null), ]; - ExportService.ExportAggregation agg = ExportService.AggregateResults(results, Array.Empty(), filterSkipped: 0); + ExportService.ExportAggregation agg = ExportService.AggregateResults(results, [], filterSkipped: 0); Assert.Equal(2, agg.Skipped); } @@ -347,7 +388,7 @@ public void AddsFilterSkipped_ToSkippedCount() (MakeFileEntry("skipped"), null), ]; - ExportService.ExportAggregation agg = ExportService.AggregateResults(results, Array.Empty(), filterSkipped: 5); + ExportService.ExportAggregation agg = ExportService.AggregateResults(results, [], filterSkipped: 5); Assert.Equal(6, agg.Skipped); } @@ -362,7 +403,7 @@ public void CountsErrors_FromFailedResults() (MakeFileEntry("exported"), null), ]; - ExportService.ExportAggregation agg = ExportService.AggregateResults(results, Array.Empty(), filterSkipped: 0); + ExportService.ExportAggregation agg = ExportService.AggregateResults(results, [], filterSkipped: 0); Assert.Equal(2, agg.Errors); } @@ -380,13 +421,91 @@ public void CollectsAllNonNullFileEntries() (skipped, null), ]; - ExportService.ExportAggregation agg = ExportService.AggregateResults(results, Array.Empty(), filterSkipped: 0); + ExportService.ExportAggregation agg = ExportService.AggregateResults(results, [], filterSkipped: 0); Assert.Equal(2, agg.Files.Count); Assert.Same(exported, agg.Files[0]); Assert.Same(skipped, agg.Files[1]); } + [Fact] + public void ErrorEntryWithMessage_CountedAsError_AndInFiles() + { + Json.ExportFileEntry errorEntry = MakeFileEntry("error"); + + (Json.ExportFileEntry?, string?)[] results = + [ + (errorEntry, "conversion failed"), + ]; + + ExportService.ExportAggregation agg = ExportService.AggregateResults(results, [], filterSkipped: 0); + + Assert.Equal(0, agg.Exported); + Assert.Equal(0, agg.Skipped); + Assert.Equal(1, agg.Errors); + _ = Assert.Single(agg.Files); + Assert.Same(errorEntry, agg.Files[0]); + _ = Assert.Single(agg.ErrorMessages); + Assert.Equal("conversion failed", agg.ErrorMessages[0]); + } + + [Fact] + public void ExportedEntryWithError_NotCountedAsExported() + { + Json.ExportFileEntry entry = MakeFileEntry("exported"); + + (Json.ExportFileEntry?, string?)[] results = + [ + (entry, "partial failure"), + ]; + + ExportService.ExportAggregation agg = ExportService.AggregateResults(results, [], filterSkipped: 0); + + Assert.Equal(0, agg.Exported); + Assert.Equal(1, agg.Errors); + _ = Assert.Single(agg.Files); + Assert.Same(entry, agg.Files[0]); + } + + [Fact] + public void SkippedEntryWithError_NotCountedAsSkipped() + { + Json.ExportFileEntry entry = MakeFileEntry("skipped"); + + (Json.ExportFileEntry?, string?)[] results = + [ + (entry, "unexpected failure"), + ]; + + ExportService.ExportAggregation agg = ExportService.AggregateResults(results, [], filterSkipped: 0); + + Assert.Equal(0, agg.Skipped); + Assert.Equal(1, agg.Errors); + _ = Assert.Single(agg.Files); + Assert.Same(entry, agg.Files[0]); + } + + [Fact] + public void ErrorStatusWithNullError_CountedAsError() + { + Json.ExportFileEntry errorEntry = MakeFileEntry("error"); + + (Json.ExportFileEntry?, string?)[] results = + [ + (errorEntry, null), + ]; + + ExportService.ExportAggregation agg = ExportService.AggregateResults(results, [], filterSkipped: 0); + + Assert.Equal(0, agg.Exported); + Assert.Equal(0, agg.Skipped); + Assert.Equal(1, agg.Errors); + _ = Assert.Single(agg.Files); + Assert.Same(errorEntry, agg.Files[0]); + _ = Assert.Single(agg.ErrorMessages); + Assert.Contains("Error processing", agg.ErrorMessages[0]); + } + [Fact] public void IncludesScanErrorsAndNewErrors_InErrorMessages() { diff --git a/CodeWalker.Cli/Tests/Helpers/FilterTests.cs b/CodeWalker.Cli/Tests/Helpers/FilterTests.cs index 48fbef2d8..29ab32f48 100644 --- a/CodeWalker.Cli/Tests/Helpers/FilterTests.cs +++ b/CodeWalker.Cli/Tests/Helpers/FilterTests.cs @@ -6,86 +6,95 @@ namespace CodeWalker.Cli.Tests.Helpers; public sealed class FilterTests { - [Fact] - public void Normalize_NullOrEmpty_ReturnsEmpty() + [Theory] + // Null or empty returns empty + [InlineData(new string[] { }, null)] + [InlineData(new string[] { }, new string[] { })] + // Trim and lowercase + [InlineData(new[] { ".ydr", "foo" }, new[] { " .YDR ", "Foo" })] + // Strips blank entries + [InlineData(new[] { "a", "b" }, new[] { "a", "", " ", "b" })] + // Preserves wildcards and lowercases + [InlineData(new[] { "*.ydr" }, new[] { " *.YDR " })] + [InlineData(new[] { "model?.ydr" }, new[] { "Model?.YDR" })] + public void Normalize_ReturnsExpectedResult(string[] expected, string[]? input) { - Assert.Empty(Filter.Normalize(null)); - Assert.Empty(Filter.Normalize([])); + string[] result = Filter.Normalize(input); + Assert.Equal(expected, result); } - [Fact] - public void Normalize_TrimsAndLowercases() + [Theory] +#pragma warning disable format + // No filters + [InlineData(true, "anything.ydr")] + [InlineData(true, "anything.ydr", null)] + // Empty path + [InlineData(false, "", ".ydr")] + [InlineData(true, "", null)] + // Extension with dot + [InlineData(true, "model.ydr", ".ydr")] + [InlineData(false, "model.ytd", ".ydr")] + // Extension without dot + [InlineData(true, "model.ydr", "ydr")] + [InlineData(false, "model.ytd", "ydr")] + // Wildcard pattern + [InlineData(true, "model.ydr", "*.ydr")] + [InlineData(true, "dir/model.ydr", "*.ydr")] + [InlineData(false, "model.ytd", "*.ydr")] + [InlineData(false, "dir/model.ytd", "*.ydr")] + // Path pattern + [InlineData(true, "vehicles/foo.ydr", "vehicles/*.ydr")] + [InlineData(true, "vehicles/bar.ydr", "vehicles/*.ydr")] + [InlineData(false, "peds/ped.ydr", "vehicles/*.ydr")] + [InlineData(false, "vehicles/model.ytd", "vehicles/*.ydr")] + // Globstar pattern + [InlineData(true, "x64/dlcpacks/vehicles/car.ydr", "**/vehicles/*.ydr")] + [InlineData(true, "vehicles/car.ydr", "**/vehicles/*.ydr")] + [InlineData(false, "x64/dlcpacks/vehicles/car.ytd", "**/vehicles/*.ydr")] + [InlineData(false, "vehicles/car.ytd", "**/vehicles/*.ydr")] + [InlineData(false, "x64/dlcpacks/peds/foo.ydr", "**/vehicles/*.ydr")] + [InlineData(false, "peds/bar.ydr", "**/vehicles/*.ydr")] + // Case insensitive + [InlineData(true, "MODEL.YDR", ".ydr")] + [InlineData(true, "MODEL.YDR", "ydr")] + [InlineData(true, "MODEL.YDR", "*.ydr")] + [InlineData(true, "X64/DLCPACKS/VEHICLES/CAR.YDR", "**/vehicles/*.ydr")] + [InlineData(true, "VEHICLES/FOO.YDR", "vehicles/*.ydr")] + [InlineData(false, "X64/DLCPACKS/VEHICLES/CAR.YTD", "**/vehicles/*.ydr")] + [InlineData(false, "VEHICLES/FOO.YTD", "vehicles/*.ydr")] + [InlineData(false, "X64/DLCPACKS/PEDS/FOO.YDR", "**/vehicles/*.ydr")] + [InlineData(false, "PEDS/BAR.YDR", "**/vehicles/*.ydr")] + // Single-char wildcard + [InlineData(true, "model1.ydr", "model?.ydr")] + [InlineData(true, "modelA.ydr", "model?.ydr")] + [InlineData(false, "modelAB.ydr", "model?.ydr")] + [InlineData(false, "model.ydr", "model?.ydr")] + [InlineData(true, "a.ydr", "?.ydr")] + [InlineData(false, "ab.ydr", "?.ydr")] + // Single-char wildcard in path + [InlineData(true, "v1/car.ydr", "v?/*.ydr")] + [InlineData(false, "vx/car.ytd", "v?/*.ydr")] + // Standalone ** (no trailing /) + [InlineData(true, "a/b/c.ydr", "**.ydr")] + [InlineData(true, "c.ydr", "**.ydr")] + [InlineData(false, "a/b/c.ytd", "**.ydr")] + // Path pattern matched at mid-path boundary + [InlineData(true, "x64/vehicles/car.ydr", "vehicles/*.ydr")] + [InlineData(true, "a/b/vehicles/car.ydr", "vehicles/*.ydr")] + [InlineData(false, "x64/vehicles/car.ytd", "vehicles/*.ydr")] + [InlineData(false, "x64/notvehicles/car.ydr", "vehicles/*.ydr")] + // Backslash normalized + [InlineData(true, "vehicles/car.ydr", "vehicles\\*.ydr")] + [InlineData(false, "vehicles/car.ytd", "vehicles\\*.ydr")] + // Multiple patterns + [InlineData(true, "model.ydr", "vehicles/*.ydr", "*.ydr")] + [InlineData(false, "model.ytd", "vehicles/*.ydr", "*.ydr")] + [InlineData(true, "vehicles/car.ydr", "peds/*.ydr", "vehicles/*.ydr")] + [InlineData(false, "vehicles/car.ytd", "peds/*.ydr", "vehicles/*.ydr")] +#pragma warning restore format + public void Matches_ReturnsExpectedResult(bool expected, string path, params string[]? filters) { - string[] result = Filter.Normalize([" .YDR ", "Foo"]); - Assert.Equal([".ydr", "foo"], result); - } - - [Fact] - public void Normalize_StripsBlankEntries() - { - string[] result = Filter.Normalize(["a", "", " ", "b"]); - Assert.Equal(["a", "b"], result); - } - - [Fact] - public void Matches_NoFilters_MatchesEverything() - { - Assert.True(Filter.Matches("anything.ydr", null)); - Assert.True(Filter.Matches("anything.ydr", [])); - } - - [Fact] - public void Matches_ExtensionWithDot() - { - string[] filters = [".ydr"]; - Assert.True(Filter.Matches("model.ydr", filters)); - Assert.False(Filter.Matches("model.ytd", filters)); - } - - [Fact] - public void Matches_ExtensionWithoutDot() - { - string[] filters = ["ydr"]; - Assert.True(Filter.Matches("model.ydr", filters)); - Assert.False(Filter.Matches("model.ytd", filters)); - } - - [Fact] - public void Matches_WildcardPattern() - { - string[] filters = ["*.ydr"]; - Assert.True(Filter.Matches("model.ydr", filters)); - Assert.True(Filter.Matches("dir/model.ydr", filters)); - Assert.False(Filter.Matches("model.ytd", filters)); - } - - [Fact] - public void Matches_PathPattern() - { - string[] filters = ["vehicles/*.ydr"]; - Assert.True(Filter.Matches("vehicles/car.ydr", filters)); - Assert.False(Filter.Matches("peds/ped.ydr", filters)); - } - - [Fact] - public void Matches_GlobstarPattern() - { - string[] filters = ["**/vehicles/*.ydr"]; - Assert.True(Filter.Matches("x64/dlcpacks/vehicles/car.ydr", filters)); - Assert.True(Filter.Matches("vehicles/car.ydr", filters)); - } - - [Fact] - public void Matches_CaseInsensitive() - { - string[] filters = [".ydr"]; - Assert.True(Filter.Matches("MODEL.YDR", filters)); - } - - [Fact] - public void Matches_BackslashNormalized() - { - string[] filters = ["vehicles\\*.ydr"]; - Assert.True(Filter.Matches("vehicles/car.ydr", filters)); + bool result = Filter.Matches(path, filters); + Assert.Equal(expected, result); } } diff --git a/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs b/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs index e45a67d48..c24052fdc 100644 --- a/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs +++ b/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs @@ -1,6 +1,4 @@ -using System; using System.IO; -using System.Reflection; using System.Threading.Tasks; using CodeWalker.Cli.Helpers; @@ -11,20 +9,16 @@ namespace CodeWalker.Cli.Tests.Helpers; public sealed class ProgressBarTests { - private static int GetCurrent(ProgressBar bar) => - (int)typeof(ProgressBar) - .GetField("_current", BindingFlags.NonPublic | BindingFlags.Instance)! - .GetValue(bar)!; + // ── Helper ────────────────────────────────────────────────────────── - private static bool GetEnabled(ProgressBar bar) => - (bool)typeof(ProgressBar) - .GetField("_enabled", BindingFlags.NonPublic | BindingFlags.Instance)! - .GetValue(bar)!; - - private static void ResetThrottle(ProgressBar bar) => - typeof(ProgressBar) - .GetField("_lastUpdate", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(bar, DateTime.MinValue); + /// Increments the bar times, optionally passing a file on the last call. + private static void IncrementTo(ProgressBar bar, int count, string? lastFile = null) + { + for (int i = 1; i < count; i++) + bar.Increment(); + if (count > 0) + bar.Increment(lastFile); + } // ── Disabled-state tests ────────────────────────────────────────── @@ -33,7 +27,7 @@ public void Constructor_disabled_when_enabled_is_false() { StringWriter sw = new(); using ProgressBar bar = new(100, enabled: false, sw); - Assert.False(GetEnabled(bar)); + Assert.False(bar.Enabled); Assert.Equal("", sw.ToString()); } @@ -42,7 +36,7 @@ public void Constructor_disabled_when_total_is_zero() { StringWriter sw = new(); using ProgressBar bar = new(0, enabled: true, sw); - Assert.False(GetEnabled(bar)); + Assert.False(bar.Enabled); } [Fact] @@ -50,7 +44,7 @@ public void Constructor_disabled_when_total_is_negative() { StringWriter sw = new(); using ProgressBar bar = new(-5, enabled: true, sw); - Assert.False(GetEnabled(bar)); + Assert.False(bar.Enabled); } // ── Enabled-state tests ─────────────────────────────────────────── @@ -60,7 +54,7 @@ public void Constructor_enabled_with_custom_writer() { StringWriter sw = new(); using ProgressBar bar = new(100, enabled: true, sw); - Assert.True(GetEnabled(bar)); + Assert.True(bar.Enabled); } [Fact] @@ -81,10 +75,11 @@ public void Render_at_50_percent_has_half_filled_bar() { StringWriter sw = new(); using ProgressBar bar = new(100, enabled: true, sw, windowWidth: 120); + // Increment to 49 (throttled, no renders) + IncrementTo(bar, 49); _ = sw.GetStringBuilder().Clear(); - bar.Update(100 / 2); // 50 == total bypasses throttle? No, 50 < 100. Need to reset throttle. - ResetThrottle(bar); - bar.Update(50); + bar.ResetThrottle(); + bar.Increment(); // 50th — renders after throttle reset string output = sw.ToString(); // 50% => filled = (int)(0.5 * 40) = 20 Assert.Contains(new string('=', 20) + ">", output); @@ -97,7 +92,7 @@ public void Render_at_100_percent_has_full_bar_no_cursor() StringWriter sw = new(); using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 120); _ = sw.GetStringBuilder().Clear(); - bar.Update(10); // == total, bypasses throttle + IncrementTo(bar, 10); // == total, bypasses throttle string output = sw.ToString(); Assert.Contains(new string('=', 40) + "]", output); Assert.DoesNotContain(">", output); @@ -112,7 +107,7 @@ public void Render_shows_current_file() StringWriter sw = new(); using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 120); _ = sw.GetStringBuilder().Clear(); - bar.Update(10, "textures/player.ytd"); // bypasses throttle at total + IncrementTo(bar, 10, "textures/player.ytd"); // bypasses throttle at total string output = sw.ToString(); Assert.Contains("textures/player.ytd", output); } @@ -124,19 +119,35 @@ public void Render_truncates_long_file_with_ellipsis() // windowWidth=80 → maxLen = Max(10, 80-40-30) = 10 using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 80); _ = sw.GetStringBuilder().Clear(); - bar.Update(10, "very/long/path/to/some/deeply/nested/file.ytd"); + IncrementTo(bar, 10, "very/long/path/to/some/deeply/nested/file.ytd"); string output = sw.ToString(); Assert.Contains("...", output); Assert.DoesNotContain("very/long/path", output); } + [Fact] + public void Increment_renders_file_name() + { + StringWriter sw = new(); + using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 120); + _ = sw.GetStringBuilder().Clear(); + // Increment 10 times to hit total (bypasses throttle) + for (int i = 0; i < 9; i++) + bar.Increment(); + _ = sw.GetStringBuilder().Clear(); + bar.Increment("models/vehicle.yft"); + string output = sw.ToString(); + Assert.Contains("models/vehicle.yft", output); + Assert.Contains("(10/10)", output); + } + [Fact] public void Render_shows_short_file_without_truncation() { StringWriter sw = new(); using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 200); _ = sw.GetStringBuilder().Clear(); - bar.Update(10, "short.ytd"); + IncrementTo(bar, 10, "short.ytd"); string output = sw.ToString(); Assert.Contains("short.ytd", output); Assert.DoesNotContain("...", output); @@ -144,15 +155,6 @@ public void Render_shows_short_file_without_truncation() // ── State tracking tests ────────────────────────────────────────── - [Fact] - public void Update_sets_current_value() - { - StringWriter sw = new(); - using ProgressBar bar = new(100, enabled: true, sw); - bar.Update(42); - Assert.Equal(42, GetCurrent(bar)); - } - [Fact] public void Increment_advances_by_one() { @@ -161,35 +163,22 @@ public void Increment_advances_by_one() bar.Increment(); bar.Increment(); bar.Increment(); - Assert.Equal(3, GetCurrent(bar)); - } - - [Fact] - public void Update_and_Increment_can_interleave() - { - StringWriter sw = new(); - using ProgressBar bar = new(100, enabled: true, sw); - bar.Update(10); - bar.Increment(); - Assert.Equal(11, GetCurrent(bar)); + Assert.Equal(3, bar.Current); } // ── Throttle tests ──────────────────────────────────────────────── [Fact] - public void Throttle_skips_rapid_updates() + public void Throttle_skips_rapid_increments() { StringWriter sw = new(); using ProgressBar bar = new(100, enabled: true, sw, windowWidth: 80); _ = sw.GetStringBuilder().Clear(); - // Rapid updates — only the first and last should render + // Rapid increments within the 50ms throttle window — none should render for (int i = 1; i <= 50; i++) - bar.Update(i); + bar.Increment(); string output = sw.ToString(); - // We should see (1/100) from the first un-throttled call - // but NOT every intermediate value - Assert.Contains("(1/100)", output); - Assert.DoesNotContain("(2/100)", output); + Assert.Equal("", output); } [Fact] @@ -198,8 +187,8 @@ public void Throttle_bypassed_at_total() StringWriter sw = new(); using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 80); _ = sw.GetStringBuilder().Clear(); - // Update to total always renders even within throttle window - bar.Update(10); + // Increment to total always renders even within throttle window + IncrementTo(bar, 10); Assert.Contains("(10/10)", sw.ToString()); } @@ -209,9 +198,9 @@ public void Throttle_reset_allows_render() StringWriter sw = new(); using ProgressBar bar = new(100, enabled: true, sw, windowWidth: 80); _ = sw.GetStringBuilder().Clear(); - ResetThrottle(bar); - bar.Update(25); - Assert.Contains("(25/100)", sw.ToString()); + bar.ResetThrottle(); + bar.Increment(); + Assert.Contains("(1/100)", sw.ToString()); } // ── Dispose tests ───────────────────────────────────────────────── @@ -251,19 +240,62 @@ public void Concurrent_increments_are_thread_safe() _ = Parallel.For(0, total, _ => bar.Increment()); - Assert.Equal(total, GetCurrent(bar)); + Assert.Equal(total, bar.Current); + } + + // ── Disabled-state mutation tests ────────────────────────────────── + + [Fact] + public void Increment_on_disabled_bar_writes_nothing() + { + StringWriter sw = new(); + using ProgressBar bar = new(100, enabled: false, sw); + bar.Increment(); + Assert.Equal("", sw.ToString()); } + // ── Clamping tests ────────────────────────────────────────────── + [Fact] - public void Concurrent_updates_do_not_throw() + public void Increment_clamps_current_at_total() { StringWriter sw = new(); - using ProgressBar bar = new(1000, enabled: true, sw); + using ProgressBar bar = new(3, enabled: true, sw); + for (int i = 0; i < 10; i++) + bar.Increment(); + Assert.Equal(3, bar.Current); + } + + // ── Render exception handling tests ────────────────────────────── + + [Fact] + public void Render_swallows_IOException_from_writer() + { + ThrowingWriter tw = new(); + using ProgressBar bar = new(10, enabled: true, tw, windowWidth: 80); + // Constructor render hit the throwing writer and didn't propagate + // Further increments should also not throw + IncrementTo(bar, 10); + } + + private sealed class ThrowingWriter : StringWriter + { + public override void Write(string? value) => throw new IOException("simulated"); + } - _ = Parallel.For(0, 1000, i => bar.Update(i, $"file_{i}.txt")); + // ── Dispose idempotency tests ─────────────────────────────────── - int current = GetCurrent(bar); - Assert.InRange(current, 0, 999); + [Fact] + public void Dispose_writes_exactly_one_newline() + { + StringWriter sw = new(); + ProgressBar bar = new(10, enabled: true, sw, windowWidth: 80); + _ = sw.GetStringBuilder().Clear(); + bar.Dispose(); + bar.Dispose(); + bar.Dispose(); + // Only one newline despite three Dispose calls + Assert.Equal(sw.NewLine, sw.ToString()); } // ── Full lifecycle test ─────────────────────────────────────────── @@ -275,11 +307,39 @@ public void Full_lifecycle_renders_progress_to_completion() using ProgressBar bar = new(5, enabled: true, sw, windowWidth: 120); for (int i = 0; i < 5; i++) { - ResetThrottle(bar); + bar.ResetThrottle(); bar.Increment($"step_{i}"); } string output = sw.ToString(); Assert.Contains("(5/5)", output); - Assert.Equal(5, GetCurrent(bar)); + Assert.Equal(5, bar.Current); + } + + // ── Edge case tests ────────────────────────────────────────────── + + [Fact] + public void Increment_with_empty_file_name_does_not_display_file() + { + StringWriter sw = new(); + using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 120); + _ = sw.GetStringBuilder().Clear(); + IncrementTo(bar, 10, ""); + string output = sw.ToString(); + Assert.Contains("(10/10)", output); + // Empty file name should not add extra content between stats and padding + Assert.DoesNotContain("...", output); + } + + [Fact] + public void Render_at_narrow_window_truncates_to_fit() + { + StringWriter sw = new(); + using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 30); + _ = sw.GetStringBuilder().Clear(); + IncrementTo(bar, 10, "some/path/to/file.ytd"); + string output = sw.ToString(); + Assert.NotEmpty(output); + // Output must be clamped to windowWidth - 1 to prevent wrapping + Assert.True(output.Length <= 29, $"Output ({output.Length} chars) should not exceed window width - 1 (29)"); } } diff --git a/CodeWalker.Cli/Tests/Helpers/SizeFormatTests.cs b/CodeWalker.Cli/Tests/Helpers/SizeFormatTests.cs index 066e1da73..9c6f457f2 100644 --- a/CodeWalker.Cli/Tests/Helpers/SizeFormatTests.cs +++ b/CodeWalker.Cli/Tests/Helpers/SizeFormatTests.cs @@ -8,78 +8,150 @@ namespace CodeWalker.Cli.Tests.Helpers; public sealed class SizeFormatTests { - [Fact] - public void IEC_ZeroBytes() => - Assert.Equal("0 B", SizeFormat.IEC.ToFormattedString(0)); - - [Fact] - public void IEC_ExactBoundaries() - { - Assert.Equal("1 B", SizeFormat.IEC.ToFormattedString(1)); - Assert.Equal("1 KiB", SizeFormat.IEC.ToFormattedString(1024)); - Assert.Equal("1 MiB", SizeFormat.IEC.ToFormattedString(1024 * 1024)); - Assert.Equal("1 GiB", SizeFormat.IEC.ToFormattedString(1024L * 1024 * 1024)); - Assert.Equal("1 TiB", SizeFormat.IEC.ToFormattedString(1024L * 1024 * 1024 * 1024)); - Assert.Equal("1 PiB", SizeFormat.IEC.ToFormattedString(1024L * 1024 * 1024 * 1024 * 1024)); - Assert.Equal("1024 PiB", SizeFormat.IEC.ToFormattedString(1024L * 1024 * 1024 * 1024 * 1024 * 1024)); - } - - [Fact] - public void IEC_FractionalValues() - { - Assert.Equal("1.5 KiB", SizeFormat.IEC.ToFormattedString(1536)); - Assert.Equal("1.5 MiB", SizeFormat.IEC.ToFormattedString(1536 * 1024)); - Assert.Equal("1.5 GiB", SizeFormat.IEC.ToFormattedString(1536L * 1024 * 1024)); - Assert.Equal("1.5 TiB", SizeFormat.IEC.ToFormattedString(1536L * 1024 * 1024 * 1024)); - Assert.Equal("1.5 PiB", SizeFormat.IEC.ToFormattedString(1536L * 1024 * 1024 * 1024 * 1024)); - Assert.Equal("1536 PiB", SizeFormat.IEC.ToFormattedString(1536L * 1024 * 1024 * 1024 * 1024 * 1024)); - } - - [Fact] - public void SI_ExactBoundaries() + [Theory] +#pragma warning disable format + // Zero bytes + [InlineData("0 B", 0)] + // Positive edge boundaries + [InlineData("999 B", 999)] + [InlineData("999 KB", 999_000)] + [InlineData("999 MB", 999_000_000)] + [InlineData("999 GB", 999_000_000_000)] + [InlineData("999 TB", 999_000_000_000_000)] + [InlineData("999 PB", 999_000_000_000_000_000)] + [InlineData("999.99 PB", 999_990_000_000_000_000)] + // Negative edge boundaries + [InlineData("-999 B", -999)] + [InlineData("-999 KB", -999_000)] + [InlineData("-999 MB", -999_000_000)] + [InlineData("-999 GB", -999_000_000_000)] + [InlineData("-999 TB", -999_000_000_000_000)] + [InlineData("-999 PB", -999_000_000_000_000_000)] + [InlineData("-999.99 PB", -999_990_000_000_000_000)] + // Positive exact boundaries + [InlineData("1 B", 1)] + [InlineData("1 KB", 1_000)] + [InlineData("1 MB", 1_000_000)] + [InlineData("1 GB", 1_000_000_000)] + [InlineData("1 TB", 1_000_000_000_000)] + [InlineData("1 PB", 1_000_000_000_000_000)] + [InlineData("1000 PB", 1_000_000_000_000_000_000)] + // Negative exact boundaries + [InlineData("-1 B", -1)] + [InlineData("-1 KB", -1_000)] + [InlineData("-1 MB", -1_000_000)] + [InlineData("-1 GB", -1_000_000_000)] + [InlineData("-1 TB", -1_000_000_000_000)] + [InlineData("-1 PB", -1_000_000_000_000_000)] + [InlineData("-1000 PB", -1_000_000_000_000_000_000)] + // Positive fractional values + [InlineData("1.5 KB", 1_500)] + [InlineData("1.5 MB", 1_500_000)] + [InlineData("1.5 GB", 1_500_000_000)] + [InlineData("1.5 TB", 1_500_000_000_000)] + [InlineData("1.5 PB", 1_500_000_000_000_000)] + [InlineData("1500 PB", 1_500_000_000_000_000_000)] + // Negative fractional values + [InlineData("-1.5 KB", -1_500)] + [InlineData("-1.5 MB", -1_500_000)] + [InlineData("-1.5 GB", -1_500_000_000)] + [InlineData("-1.5 TB", -1_500_000_000_000)] + [InlineData("-1.5 PB", -1_500_000_000_000_000)] + [InlineData("-1500 PB", -1_500_000_000_000_000_000)] + // Extremes + [InlineData("9223.37 PB", long.MaxValue)] + [InlineData("-9223.37 PB", long.MinValue)] + // Small non-boundary values + [InlineData("42 B", 42)] + [InlineData("500 B", 500)] + // Rounding (display rounds up to next whole unit) + [InlineData("2 KB", 1_999)] + [InlineData("1000 KB", 999_999)] +#pragma warning restore format + public void SI_ToFormattedString_ReturnsExpectedResults(string expected, long bytes) { - Assert.Equal("1 B", SizeFormat.SI.ToFormattedString(1)); - Assert.Equal("1 KB", SizeFormat.SI.ToFormattedString(1000)); - Assert.Equal("1 MB", SizeFormat.SI.ToFormattedString(1_000_000)); - Assert.Equal("1 GB", SizeFormat.SI.ToFormattedString(1_000_000_000)); - Assert.Equal("1 TB", SizeFormat.SI.ToFormattedString(1_000_000_000_000)); - Assert.Equal("1 PB", SizeFormat.SI.ToFormattedString(1_000_000_000_000_000)); - Assert.Equal("1000 PB", SizeFormat.SI.ToFormattedString(1_000_000_000_000_000_000)); + string result = SizeFormat.SI.ToFormattedString(bytes); + Assert.Equal(expected, result); } - [Fact] - public void SI_FractionalValues() + [Theory] +#pragma warning disable format + // Zero bytes + [InlineData("0 B", 0)] + // Positive edge boundaries + [InlineData("1023 B", 1023L)] + [InlineData("1023 KiB", 1023L * (1L << 10))] + [InlineData("1023 MiB", 1023L * (1L << 20))] + [InlineData("1023 GiB", 1023L * (1L << 30))] + [InlineData("1023 TiB", 1023L * (1L << 40))] + [InlineData("1023 PiB", 1023L * (1L << 50))] + [InlineData("1023.99 PiB", (long)(1023.99 * (1L << 50)))] + // Negative edge boundaries + [InlineData("-1023 B", -1023L)] + [InlineData("-1023 KiB", -1023L * (1L << 10))] + [InlineData("-1023 MiB", -1023L * (1L << 20))] + [InlineData("-1023 GiB", -1023L * (1L << 30))] + [InlineData("-1023 TiB", -1023L * (1L << 40))] + [InlineData("-1023 PiB", -1023L * (1L << 50))] + [InlineData("-1023.99 PiB", (long)(-1023.99 * (1L << 50)))] + // Positive exact boundaries + [InlineData("1 B", 1L)] + [InlineData("1 KiB", 1L << 10)] + [InlineData("1 MiB", 1L << 20)] + [InlineData("1 GiB", 1L << 30)] + [InlineData("1 TiB", 1L << 40)] + [InlineData("1 PiB", 1L << 50)] + [InlineData("1024 PiB", 1L << 60)] + // Negative exact boundaries + [InlineData("-1 B", -1L)] + [InlineData("-1 KiB", -1L << 10)] + [InlineData("-1 MiB", -1L << 20)] + [InlineData("-1 GiB", -1L << 30)] + [InlineData("-1 TiB", -1L << 40)] + [InlineData("-1 PiB", -1L << 50)] + [InlineData("-1024 PiB", -1L << 60)] + // Positive fractional values + [InlineData("1.5 KiB", 1536L)] + [InlineData("1.5 MiB", 1536L * (1L << 10))] + [InlineData("1.5 GiB", 1536L * (1L << 20))] + [InlineData("1.5 TiB", 1536L * (1L << 30))] + [InlineData("1.5 PiB", 1536L * (1L << 40))] + [InlineData("1536 PiB", 1536L * (1L << 50))] + // Negative fractional values + [InlineData("-1.5 KiB", -1536L)] + [InlineData("-1.5 MiB", -1536L * (1L << 10))] + [InlineData("-1.5 GiB", -1536L * (1L << 20))] + [InlineData("-1.5 TiB", -1536L * (1L << 30))] + [InlineData("-1.5 PiB", -1536L * (1L << 40))] + [InlineData("-1536 PiB", -1536L * (1L << 50))] + // Extremes + [InlineData("8192 PiB", long.MaxValue)] + [InlineData("-8192 PiB", long.MinValue)] + // Small non-boundary values + [InlineData("42 B", 42)] + [InlineData("500 B", 500)] + // Rounding (display rounds up to next whole unit) + [InlineData("2 KiB", (1L << 10) + 1023)] + [InlineData("1024 KiB", (1L << 20) - 1)] +#pragma warning restore format + public void IEC_ToFormattedString_ReturnsExpectedResults(string expected, long bytes) { - Assert.Equal("1.5 KB", SizeFormat.SI.ToFormattedString(1500)); - Assert.Equal("1.5 MB", SizeFormat.SI.ToFormattedString(1_500_000)); - Assert.Equal("1.5 GB", SizeFormat.SI.ToFormattedString(1_500_000_000)); - Assert.Equal("1.5 TB", SizeFormat.SI.ToFormattedString(1_500_000_000_000)); - Assert.Equal("1.5 PB", SizeFormat.SI.ToFormattedString(1_500_000_000_000_000)); - Assert.Equal("1500 PB", SizeFormat.SI.ToFormattedString(1_500_000_000_000_000_000)); + string result = SizeFormat.IEC.ToFormattedString(bytes); + Assert.Equal(expected, result); } [Fact] - public void SmallBytes_NoSuffix() + public void InvalidFormat_Throws() { - Assert.Equal("1023 B", SizeFormat.IEC.ToFormattedString(1023)); - Assert.Equal("999 B", SizeFormat.SI.ToFormattedString(999)); - } + const int range = 42; // Arbitrary range to test values around the defined enum members + for (int i = -range; i <= range; i++) + { + if (Enum.IsDefined(typeof(SizeFormat), i)) + continue; - [Fact] - public void NegativeBytes_FormatsCorrectly() - { - Assert.Equal("-1 B", SizeFormat.IEC.ToFormattedString(-1)); - Assert.Equal("-1 KiB", SizeFormat.IEC.ToFormattedString(-1024)); - Assert.Equal("-1 PiB", SizeFormat.IEC.ToFormattedString(-1024L * 1024 * 1024 * 1024 * 1024)); - Assert.Equal("-1024 PiB", SizeFormat.IEC.ToFormattedString(-1024L * 1024 * 1024 * 1024 * 1024 * 1024)); - - Assert.Equal("-1 B", SizeFormat.SI.ToFormattedString(-1)); - Assert.Equal("-1 KB", SizeFormat.SI.ToFormattedString(-1000)); - Assert.Equal("-1 PB", SizeFormat.SI.ToFormattedString(-1_000_000_000_000_000)); - Assert.Equal("-1000 PB", SizeFormat.SI.ToFormattedString(-1_000_000_000_000_000_000)); + SizeFormat invalid = (SizeFormat)i; + _ = Assert.Throws(() => + invalid.ToFormattedString(1337)); + } } - - [Fact] - public void InvalidFormat_Throws() => - Assert.Throws(() => ((SizeFormat)999).ToFormattedString(1024)); } diff --git a/CodeWalker.Cli/Tests/RpfServiceTests.cs b/CodeWalker.Cli/Tests/RpfServiceTests.cs index 02f38caa7..077b10c2e 100644 --- a/CodeWalker.Cli/Tests/RpfServiceTests.cs +++ b/CodeWalker.Cli/Tests/RpfServiceTests.cs @@ -144,6 +144,18 @@ public void GetFileType_Resource() => public void GetFileType_Binary() => Assert.Equal("binary", RpfService.GetFileType(new RpfBinaryFileEntry())); + private sealed class StubFileEntry : RpfFileEntry + { + public override long GetFileSize() => 0; + public override void SetFileSize(uint s) { } + public override void Read(DataReader reader) { } + public override void Write(DataWriter writer) { } + } + + [Fact] + public void GetFileType_Unknown() => + Assert.Equal("unknown", RpfService.GetFileType(new StubFileEntry())); + // --- CollectFiles --- private static RpfBinaryFileEntry MakeEntry(string name, string? path = null) => @@ -205,6 +217,37 @@ public void CollectFiles_AppliesFilter() Assert.Equal("test.ydr", files[0].entry.Name); } + [Fact] + public void CollectFiles_MultipleFilters() + { + RpfBinaryFileEntry e1 = MakeEntry("a.ydr"); + RpfBinaryFileEntry e2 = MakeEntry("b.ytd"); + RpfBinaryFileEntry e3 = MakeEntry("c.yft"); + RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = [e1, e2, e3] }; + List<(RpfFile rpf, RpfFileEntry entry)> files = + RpfService.CollectFiles(rpf, ["*.ydr", "*.ytd"], recursive: false); + Assert.Equal(2, files.Count); + } + + [Fact] + public void CollectFiles_Recursive_WithFilter() + { + RpfBinaryFileEntry parentYdr = MakeEntry("a.ydr"); + RpfBinaryFileEntry parentYtd = MakeEntry("b.ytd"); + RpfBinaryFileEntry childYdr = MakeEntry("c.ydr"); + RpfBinaryFileEntry childYtd = MakeEntry("d.ytd"); + RpfFile child = new("child", "child.rpf", 0) { AllEntries = [childYdr, childYtd] }; + RpfFile parent = new("parent", "parent.rpf", 0) + { + AllEntries = [parentYdr, parentYtd], + Children = [child], + }; + List<(RpfFile rpf, RpfFileEntry entry)> files = + RpfService.CollectFiles(parent, ["*.ydr"], recursive: true); + Assert.Equal(2, files.Count); + Assert.All(files, f => Assert.EndsWith(".ydr", f.entry.Name)); + } + [Fact] public void CollectFiles_Recursive_IncludesChildren() { @@ -305,6 +348,17 @@ public void CountNonRpfFiles_NullEntries_ReturnsZero() Assert.Equal(0, RpfService.CountNonRpfFiles(rpf, recursive: false)); } + [Fact] + public void CountNonRpfFiles_SkipsDirectoryEntries() + { + RpfDirectoryEntry dirEntry = new() { Name = "subdir", NameLower = "subdir", Path = "subdir" }; + RpfFile rpf = new("test", "test.rpf", 0) + { + AllEntries = [dirEntry, MakeEntry("a.ydr")], + }; + Assert.Equal(1, RpfService.CountNonRpfFiles(rpf, recursive: false)); + } + // --- ReportError --- private static Json.ExportResult MakeBaseResult(string[]? errors = null) => @@ -367,16 +421,22 @@ public void ReportError_Json_WritesToStdout() public void ReportError_Json_PreservesExistingErrors() { TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; try { StringWriter sw = new(); Console.SetOut(sw); + Console.SetError(new StringWriter()); _ = RpfService.ReportError("new error", json: true, MakeBaseResult(["old error"])); string output = sw.ToString(); Assert.Contains("old error", output); Assert.Contains("new error", output); } - finally { Console.SetOut(origOut); } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } } [Fact] From 123d5d9b1bfee53d0a2ad9a481089fd61a23ce91 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:06:50 +0200 Subject: [PATCH 21/45] refactor(cli): split the hash and stat handlers for testing Both handlers computed and printed in one pass, so nothing could be tested without capturing console output and parsing it back. Each is a Collect step returning the JSON result record plus one Print step per output format, and the tests call those directly. hash's encoding switch became ParseEncoding, which throws on an unknown name instead of returning an error result from the middle of Execute. The 'utf8' spelling that --encoding never accepted is gone; the default is the 'utf-8' the option documents. stat's JSON gained the *Formatted counterparts the other commands already emitted alongside their raw sizes: compressedSize, uncompressedSize, and the per-extension avg, min and max. --- CodeWalker.Cli/ExportService.cs | 18 +- CodeWalker.Cli/Handlers/HashHandler.cs | 172 +++-- CodeWalker.Cli/Handlers/StatHandler.cs | 339 ++++++---- CodeWalker.Cli/Helpers/Filter.cs | 7 +- CodeWalker.Cli/Helpers/ProgressBar.cs | 10 +- CodeWalker.Cli/Json/StatResult.cs | 15 + CodeWalker.Cli/RpfService.cs | 4 - .../Tests/Handlers/HashHandlerTests.cs | 616 ++++++++++-------- .../Tests/Handlers/StatHandlerTests.cs | 582 ++++++++++++++++- .../Tests/Helpers/ProgressBarTests.cs | 2 +- CodeWalker.Cli/Tests/PolyfillsTests.cs | 25 +- 11 files changed, 1278 insertions(+), 512 deletions(-) diff --git a/CodeWalker.Cli/ExportService.cs b/CodeWalker.Cli/ExportService.cs index 423878a64..81430043e 100644 --- a/CodeWalker.Cli/ExportService.cs +++ b/CodeWalker.Cli/ExportService.cs @@ -69,9 +69,7 @@ ExportFileProcessor processor } if (data == null) - { return (null, $"Failed to extract: {fileEntry.Path}"); - } (Json.ExportFileEntry? entry, string? error) = processor( fileEntry, @@ -81,14 +79,10 @@ ExportFileProcessor processor ); if (error != null) - { return (entry, error); - } if (entry != null) - { return (entry, null); - } return (null, $"No result for: {fileEntry.Path}"); } @@ -171,9 +165,7 @@ Json.ExportResult ErrorResult(string[] errorMessages) => options.Rpf.Json ); if (initError != null) - { return RpfService.ReportError(initError, options.Rpf.Json, ErrorResult([])); - } try { @@ -186,16 +178,12 @@ Json.ExportResult ErrorResult(string[] errorMessages) => ); if (!options.Rpf.Json && options.DryRun) - { Console.Error.WriteLine("Dry run mode - no files will be exported"); - } string outputDir = options.OutputPath; if (!options.DryRun && !Directory.Exists(outputDir)) - { _ = Directory.CreateDirectory(outputDir); - } List<(RpfFile rpf, RpfFileEntry entry)> filesToExport = RpfService.CollectFiles( rpf, @@ -214,7 +202,7 @@ Json.ExportResult ErrorResult(string[] errorMessages) => using ( ProgressBar progress = new( filesToExport.Count, - options.Progress && !options.Rpf.Json + options is { Progress: true, Rpf.Json: false } ) ) { @@ -244,9 +232,7 @@ Json.ExportResult ErrorResult(string[] errorMessages) => if ( result.entry != null - && options.Rpf.Verbose - && !options.Rpf.Json - && !options.Progress + && options is { Rpf: { Verbose: true, Json: false }, Progress: false } ) { if (options.DryRun) diff --git a/CodeWalker.Cli/Handlers/HashHandler.cs b/CodeWalker.Cli/Handlers/HashHandler.cs index 2b841fe85..717eef2ce 100644 --- a/CodeWalker.Cli/Handlers/HashHandler.cs +++ b/CodeWalker.Cli/Handlers/HashHandler.cs @@ -14,8 +14,7 @@ internal sealed record HashOptions public required string Encoding { get; init; } public required bool Json { get; init; } - public const string DefaultEncoding = "utf8"; - public const JenkHashInputEncoding DefaultJenkHashEncoding = JenkHashInputEncoding.UTF8; + public const string DefaultEncoding = "UTF-8"; } internal static class HashHandler @@ -53,7 +52,7 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul HashOptions options = new() { Inputs = parseResult.GetRequiredValue(inputOption), - Encoding = parseResult.GetValue(encodingOption) ?? HashOptions.DefaultEncoding, + Encoding = parseResult.GetRequiredValue(encodingOption), Json = parseResult.GetValue(jsonOption), }; return Execute(options, cancellationToken); @@ -62,85 +61,132 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul return command; } + /// + /// Executes the hash generation based on the provided options. + /// It handles both human-readable and JSON output formats, and gracefully manages cancellation and errors. + /// + /// The options containing the input strings, encoding, and output format preferences. + /// A cancellation token to observe while performing the hashing operation. + /// An integer exit code indicating success (0) or failure (1). public static int Execute(HashOptions options, CancellationToken cancellationToken = default) { - static Json.HashResult ErrorResult(string[] errorMessages) => - new() - { - Success = false, - Hashes = [], - ErrorMessages = errorMessages, - }; + JenkHashInputEncoding encoding = ParseEncoding(options.Encoding); - // Validate encoding - JenkHashInputEncoding encoding; - switch (options.Encoding.ToLowerInvariant()) + try { - case HashOptions.DefaultEncoding: - encoding = HashOptions.DefaultJenkHashEncoding; - break; - case "utf-8": - encoding = JenkHashInputEncoding.UTF8; - break; - case "ascii": - encoding = JenkHashInputEncoding.ASCII; - break; - default: - return RpfService.ReportError( - $"Unknown encoding: {options.Encoding}. Use 'utf-8' or 'ascii'.", - options.Json, - ErrorResult([]) - ); + Json.HashEntry[] hashes = CollectHashes(options.Inputs, encoding, cancellationToken); + if (!options.Json) + PrintHashes(hashes, cancellationToken); + else + PrintJsonHashes(hashes); + + return 0; } + catch (OperationCanceledException) + { + // Gracefully handle cancellation without printing an error message + throw; + } + catch (Exception ex) + { + return RpfService.ReportError( + ex.Message, + options.Json, + ErrorResult([])); + } + } - try + /// + /// Creates a JSON result object representing an error, with the provided error messages. + /// + /// An array of error messages to include in the result. + /// A object with success set to false and the provided error messages. + internal static Json.HashResult ErrorResult(string[] errorMessages) => + new() { - List hashes = []; + Success = false, + Hashes = [], + ErrorMessages = errorMessages, + }; - foreach (string input in options.Inputs) - { - cancellationToken.ThrowIfCancellationRequested(); - JenkHash jenkHash = new(input, encoding); + /// + /// Parses the encoding string into a enum value. + /// + /// The encoding string to parse (e.g., "utf-8", "ascii"). + /// The corresponding value. + /// Thrown if the encoding string is not recognized. + internal static JenkHashInputEncoding ParseEncoding(string encoding) => + encoding.ToUpperInvariant() switch + { + "UTF-8" => JenkHashInputEncoding.UTF8, + "ASCII" => JenkHashInputEncoding.ASCII, + _ => throw new ArgumentException($"Unknown encoding: {encoding}. Use 'utf-8' or 'ascii'."), + }; - Json.HashEntry entry = new() + /// + /// Collects the hash results for each input string and returns them as an array of objects. + /// + /// An array of input strings to hash. + /// The encoding to use for hashing the input strings. + /// A cancellation token to observe while performing the hashing operation. + /// An array of objects containing the hash results for each input string. + internal static Json.HashEntry[] CollectHashes( + string[] inputs, + JenkHashInputEncoding encoding, + CancellationToken cancellationToken + ) + { + List hashes = []; + foreach (string input in inputs) + { + cancellationToken.ThrowIfCancellationRequested(); + JenkHash jenkHash = new(input, encoding); + hashes.Add( + new Json.HashEntry() { Input = input, Hash = jenkHash.HashUint, HashSigned = jenkHash.HashInt, HashHex = jenkHash.HashHex, Encoding = jenkHash.Encoding.ToString(), - }; - - hashes.Add(entry); - - if (!options.Json) - { - Console.WriteLine($"Input: {input}"); - Console.WriteLine($" Hash (uint): {jenkHash.HashUint}"); - Console.WriteLine($" Hash (int): {jenkHash.HashInt}"); - Console.WriteLine($" Hash (hex): {jenkHash.HashHex}"); } - } + ); + } + return [.. hashes]; + } - if (options.Json) - { - Json.HashResult result = new() - { - Success = true, - Hashes = [.. hashes], - ErrorMessages = [], - }; - Console.WriteLine( - JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) - ); - } + /// + /// Prints the hash results to the console in JSON format, including the input, hash values, and encoding used. + /// + /// An array of pre-computed hash entries to serialize. + internal static void PrintJsonHashes(Json.HashEntry[] hashes) + { + Json.HashResult result = new() + { + Success = true, + Hashes = hashes, + ErrorMessages = [], + }; + Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); + } - return 0; - } - catch (OperationCanceledException) { throw; } - catch (Exception ex) + /// + /// Prints the hash results to the console in a human-readable format. + /// + /// An array of pre-computed hash entries to print. + /// A cancellation token to observe while printing. + internal static void PrintHashes( + Json.HashEntry[] entries, + CancellationToken cancellationToken + ) + { + foreach (Json.HashEntry entry in entries) { - return RpfService.ReportError(ex.Message, options.Json, ErrorResult([])); + cancellationToken.ThrowIfCancellationRequested(); + Console.WriteLine($"Input ({entry.Encoding}): {entry.Input}"); + Console.WriteLine($" Hash (uint): {entry.Hash}"); + Console.WriteLine($" Hash (int): {entry.HashSigned}"); + Console.WriteLine($" Hash (hex): {entry.HashHex}"); } } } diff --git a/CodeWalker.Cli/Handlers/StatHandler.cs b/CodeWalker.Cli/Handlers/StatHandler.cs index c13586f0f..fb8d27f77 100644 --- a/CodeWalker.Cli/Handlers/StatHandler.cs +++ b/CodeWalker.Cli/Handlers/StatHandler.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.CommandLine; +using System.Globalization; using System.IO; using System.Linq; using System.Text.Json; @@ -26,25 +27,14 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul return command; } + /// + /// Executes the stat command by validating the RPF file, collecting file entries, calculating statistics, and printing the results in either JSON or human-readable format. + /// + /// The options for the stat command, including the RPF file path, filters, and output format. + /// A cancellation token to observe while performing the operation. + /// An integer exit code indicating success (0) or failure (1). public static int Execute(RpfOptions options, CancellationToken cancellationToken = default) { - Json.StatResult ErrorResult(string[] errorMessages) => - new() - { - Success = false, - RpfFile = options.RpfPath, - TotalFiles = 0, - TotalSize = 0, - TotalSizeFormatted = "0 B", - ResourceCount = 0, - BinaryCount = 0, - CompressedSize = 0, - UncompressedSize = 0, - CompressionRatio = 0, - Extensions = [], - ErrorMessages = errorMessages, - }; - string? initError = RpfService.ValidateAndLoadKeys( options.RpfPath, options.ExePath, @@ -52,9 +42,7 @@ Json.StatResult ErrorResult(string[] errorMessages) => options.Json ); if (initError != null) - { - return RpfService.ReportError(initError, options.Json, ErrorResult([])); - } + return RpfService.ReportError(initError, options.Json, ErrorResult([], options)); try { @@ -67,9 +55,7 @@ Json.StatResult ErrorResult(string[] errorMessages) => ); if (!options.Json) - { Console.Error.WriteLine(); - } List<(RpfFile rpf, RpfFileEntry entry)> entries = RpfService.CollectFiles( rpf, @@ -77,130 +63,223 @@ Json.StatResult ErrorResult(string[] errorMessages) => options.Recursive ); - long totalSize = 0; - int resourceCount = 0; - int binaryCount = 0; - long compressedSize = 0; - long uncompressedSize = 0; + Json.StatResult result = CollectStats(entries, scanErrors, options, cancellationToken); - Dictionary extStats = []; + if (options.Json) + PrintJsonStats(result); + else + PrintStats(result, options); - foreach ((RpfFile _, RpfFileEntry fileEntry) in entries) - { - cancellationToken.ThrowIfCancellationRequested(); - long size = fileEntry.GetFileSize(); - totalSize += size; - string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); - if (string.IsNullOrEmpty(ext)) - ext = "(none)"; - - if (extStats.TryGetValue(ext, out (int count, long total, long min, long max) stat)) - { - extStats[ext] = ( - stat.count + 1, - stat.total + size, - Math.Min(stat.min, size), - Math.Max(stat.max, size) - ); - } - else - { - extStats[ext] = (1, size, size, size); - } + return scanErrors.Count > 0 ? 1 : 0; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + return RpfService.ReportError( + ex.Message, + options.Json, + ErrorResult([], options), + options.Verbose ? ex.StackTrace : null + ); + } + } - if (fileEntry is RpfResourceFileEntry rfe) - { - resourceCount++; - compressedSize += rfe.FileSize; - uncompressedSize += rfe.SystemSize + rfe.GraphicsSize; - } - else if (fileEntry is RpfBinaryFileEntry bfe) - { - binaryCount++; - compressedSize += bfe.FileSize; - uncompressedSize += bfe.FileUncompressedSize; - } - } - double compressionRatio = - uncompressedSize > 0 ? (double)compressedSize / uncompressedSize : 0; - - List extensionStats = - [ - .. extStats - .OrderByDescending(kv => kv.Value.total) - .Select(kv => new Json.ExtensionStat - { - Extension = kv.Key, - Count = kv.Value.count, - TotalSize = kv.Value.total, - TotalSizeFormatted = options.SizeFormat.ToFormattedString(kv.Value.total), - AvgSize = kv.Value.count > 0 ? kv.Value.total / kv.Value.count : 0, - MinSize = kv.Value.min, - MaxSize = kv.Value.max, - }), - ]; - - Json.StatResult result = new() - { - Success = scanErrors.Count == 0, - RpfFile = options.RpfPath, - TotalFiles = entries.Count, - TotalSize = totalSize, - TotalSizeFormatted = options.SizeFormat.ToFormattedString(totalSize), - ResourceCount = resourceCount, - BinaryCount = binaryCount, - CompressedSize = compressedSize, - UncompressedSize = uncompressedSize, - CompressionRatio = Math.Round(compressionRatio, 4), - Extensions = extensionStats, - ErrorMessages = [.. scanErrors], - }; + /// + /// Creates a JSON result object representing an error, with the provided error messages and default values for all statistics fields. + /// + /// An array of error messages to include in the result. + /// The options used to populate the RpfFile field in the result. + /// A object with success set to false, the RpfFile field set from options, and all statistics fields set to default values. + internal static Json.StatResult ErrorResult(string[] errorMessages, RpfOptions options) => + new() + { + Success = false, + RpfFile = options.RpfPath, + TotalFiles = 0, + TotalSize = 0, + TotalSizeFormatted = "0 B", + ResourceCount = 0, + BinaryCount = 0, + CompressedSize = 0, + CompressedSizeFormatted = "0 B", + UncompressedSize = 0, + UncompressedSizeFormatted = "0 B", + CompressionRatio = 0, + Extensions = [], + ErrorMessages = errorMessages, + }; - if (options.Json) + /// + /// Collects the statistics for the given list of RPF file entries, including total size, file counts, compression ratios, and extension-based statistics, and returns the results in a object. + /// + /// A list of tuples containing the RPF file and its corresponding file entry to analyze for statistics. + /// A list of error messages encountered during the scanning process, which will be included in the result. + /// The options used to populate the RpfFile field in the result and format size values. + /// A cancellation token to observe while performing the statistics collection operation. + /// A object containing the collected statistics for the RPF file entries, including total size, file counts, compression ratios, extension-based statistics, and any error messages. + internal static Json.StatResult CollectStats( + List<(RpfFile rpf, RpfFileEntry entry)> entries, + List scanErrors, + RpfOptions options, + CancellationToken cancellationToken = default) + { + long totalSize = 0; + int resourceCount = 0; + int binaryCount = 0; + long compressedSize = 0; + long uncompressedSize = 0; + + Dictionary extStats = []; + + foreach ((RpfFile _, RpfFileEntry fileEntry) in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + long size = fileEntry.GetFileSize(); + totalSize += size; + string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); + if (string.IsNullOrEmpty(ext)) + ext = "(none)"; + + if (extStats.TryGetValue(ext, out (int count, long total, long min, long max) stat)) { - Console.WriteLine( - JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + extStats[ext] = ( + stat.count + 1, + stat.total + size, + Math.Min(stat.min, size), + Math.Max(stat.max, size) ); } else { - // Table header - Console.WriteLine( - $"{"Extension",-12} {"Count",8} {"Total",14} {"Avg",14} {"Min",14} {"Max",14}" - ); - Console.WriteLine(new string('-', 78)); + extStats[ext] = (1, size, size, size); + } - foreach (Json.ExtensionStat ext in extensionStats) - { - Console.WriteLine( - $"{ext.Extension,-12} {ext.Count,8} {options.SizeFormat.ToFormattedString(ext.TotalSize),14} {options.SizeFormat.ToFormattedString(ext.AvgSize),14} {options.SizeFormat.ToFormattedString(ext.MinSize),14} {options.SizeFormat.ToFormattedString(ext.MaxSize),14}" - ); - } + if (fileEntry is RpfResourceFileEntry rfe) + { + resourceCount++; + compressedSize += rfe.FileSize; + uncompressedSize += rfe.SystemSize + rfe.GraphicsSize; + } + else if (fileEntry is RpfBinaryFileEntry bfe) + { + binaryCount++; + compressedSize += bfe.FileSize; + uncompressedSize += bfe.FileUncompressedSize; + } + } - Console.Error.WriteLine(); - Console.Error.WriteLine( - $"Total: {entries.Count} files, {options.SizeFormat.ToFormattedString(totalSize)}" - ); - Console.Error.WriteLine($"Types: {resourceCount} resource, {binaryCount} binary"); + double compressionRatio = + uncompressedSize > 0 ? (double)compressedSize / uncompressedSize : 0; - if (uncompressedSize > 0) + List extensionStats = + [ + .. extStats + .OrderByDescending(kv => kv.Value.total) + .Select(kv => new Json.ExtensionStat { - Console.Error.WriteLine( - $"Compression: {options.SizeFormat.ToFormattedString(compressedSize)} / {options.SizeFormat.ToFormattedString(uncompressedSize)} ({compressionRatio:P1} of original)" - ); - } - } + Extension = kv.Key, + Count = kv.Value.count, + TotalSize = kv.Value.total, + TotalSizeFormatted = options.SizeFormat.ToFormattedString(kv.Value.total), + AvgSize = kv.Value.count > 0 ? kv.Value.total / kv.Value.count : 0, + AvgSizeFormatted = options.SizeFormat.ToFormattedString(kv.Value.count > 0 ? kv.Value.total / kv.Value.count : 0), + MinSize = kv.Value.min, + MinSizeFormatted = options.SizeFormat.ToFormattedString(kv.Value.min), + MaxSize = kv.Value.max, + MaxSizeFormatted = options.SizeFormat.ToFormattedString(kv.Value.max), + }), + ]; - return scanErrors.Count > 0 ? 1 : 0; + return new Json.StatResult() + { + Success = scanErrors.Count == 0, + RpfFile = options.RpfPath, + TotalFiles = entries.Count, + TotalSize = totalSize, + TotalSizeFormatted = options.SizeFormat.ToFormattedString(totalSize), + ResourceCount = resourceCount, + BinaryCount = binaryCount, + CompressedSize = compressedSize, + CompressedSizeFormatted = options.SizeFormat.ToFormattedString(compressedSize), + UncompressedSize = uncompressedSize, + UncompressedSizeFormatted = options.SizeFormat.ToFormattedString(uncompressedSize), + CompressionRatio = Math.Round(compressionRatio, 4), + Extensions = extensionStats, + ErrorMessages = [.. scanErrors], + }; + } + + /// + /// Prints the collected statistics to the console in JSON format. + /// + /// The collected statistics to serialize. + internal static void PrintJsonStats(Json.StatResult result) => + Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); + + /// + /// Prints the collected statistics to the console in a human-readable format. + /// + /// The collected statistics to print. + /// The options used to format size values in the output. + internal static void PrintStats(Json.StatResult result, RpfOptions options) + { + // Collect all rows for dynamic column sizing + string[] headers = ["Extension", "Count", "Total", "Avg", "Min", "Max"]; + List rows = new(result.Extensions.Count); + foreach (Json.ExtensionStat ext in result.Extensions) + { + rows.Add([ + ext.Extension, + ext.Count.ToString(CultureInfo.InvariantCulture), + options.SizeFormat.ToFormattedString(ext.TotalSize), + options.SizeFormat.ToFormattedString(ext.AvgSize), + options.SizeFormat.ToFormattedString(ext.MinSize), + options.SizeFormat.ToFormattedString(ext.MaxSize), + ]); } - catch (OperationCanceledException) { throw; } - catch (Exception ex) + + // Calculate column widths from headers and data + int[] widths = new int[headers.Length]; + for (int i = 0; i < headers.Length; i++) + widths[i] = headers[i].Length; + + foreach (string[] row in rows) + for (int i = 0; i < row.Length; i++) + widths[i] = Math.Max(widths[i], row[i].Length); + + // Print header — first column left-aligned, rest right-aligned + Console.Write($" {headers[0].PadRight(widths[0])} "); + for (int i = 1; i < headers.Length; i++) + Console.Write($"| {headers[i].PadLeft(widths[i])} "); + Console.WriteLine(); + + // Separator with column dividers + Console.Write(new string('-', widths[0] + 2)); + for (int i = 1; i < widths.Length; i++) + Console.Write($"+{new string('-', widths[i] + 2)}"); + Console.WriteLine(); + + // Print data rows + foreach (string[] row in rows) { - return RpfService.ReportError( - ex.Message, - options.Json, - ErrorResult([]), - options.Verbose ? ex.StackTrace : null + Console.Write($" {row[0].PadRight(widths[0])} "); + for (int i = 1; i < row.Length; i++) + Console.Write($"| {row[i].PadLeft(widths[i])} "); + Console.WriteLine(); + } + + Console.Error.WriteLine(); + Console.Error.WriteLine( + $"Total: {result.TotalFiles} files, {options.SizeFormat.ToFormattedString(result.TotalSize)}" + ); + Console.Error.WriteLine($"Types: {result.ResourceCount} resource, {result.BinaryCount} binary"); + + if (result.UncompressedSize > 0) + { + string compressedStr = options.SizeFormat.ToFormattedString(result.CompressedSize); + string uncompressedStr = options.SizeFormat.ToFormattedString(result.UncompressedSize); + Console.Error.WriteLine( + $"Compression: {compressedStr} / {uncompressedStr} ({result.CompressionRatio:P1} of original)" ); } } diff --git a/CodeWalker.Cli/Helpers/Filter.cs b/CodeWalker.Cli/Helpers/Filter.cs index 1b3fc837d..76730fd5b 100644 --- a/CodeWalker.Cli/Helpers/Filter.cs +++ b/CodeWalker.Cli/Helpers/Filter.cs @@ -99,10 +99,9 @@ private static bool MatchesGlob(string input, string pattern) // Patterns with path separators match at any path boundary; // filename-only patterns are anchored to the full filename. - if (p.Contains('/', StringComparison.Ordinal)) - regexPattern = $"(?:^|/){regexPattern}$"; - else - regexPattern = $"^{regexPattern}$"; + regexPattern = p.Contains('/', StringComparison.Ordinal) + ? $"(?:^|/){regexPattern}$" + : $"^{regexPattern}$"; return new Regex(regexPattern, RegexOptions.Compiled); } diff --git a/CodeWalker.Cli/Helpers/ProgressBar.cs b/CodeWalker.Cli/Helpers/ProgressBar.cs index 8717248e1..827e964c8 100644 --- a/CodeWalker.Cli/Helpers/ProgressBar.cs +++ b/CodeWalker.Cli/Helpers/ProgressBar.cs @@ -42,9 +42,7 @@ internal sealed class ProgressBar : IDisposable internal void ResetThrottle() { lock (this._lock) - { this._throttle.Reset(); - } } /// @@ -69,7 +67,11 @@ public ProgressBar(int total, bool enabled, TextWriter? writer = null, int windo { Console.CursorVisible = false; } - catch { } + catch + { + // Ignore console errors (e.g. redirected output, no terminal) + this._ownsConsole = false; + } } this.Render(); this._throttle.Start(); @@ -91,7 +93,7 @@ private void Update(int current, string? currentFile = null) return; // Throttle updates to avoid flickering - if (this._throttle.IsRunning && this._throttle.ElapsedMilliseconds < ThrottleMs && current < this._total) + if (this._throttle is { IsRunning: true, ElapsedMilliseconds: < ThrottleMs } && current < this._total) return; this._throttle.Restart(); diff --git a/CodeWalker.Cli/Json/StatResult.cs b/CodeWalker.Cli/Json/StatResult.cs index 71a7a5250..4610f939a 100644 --- a/CodeWalker.Cli/Json/StatResult.cs +++ b/CodeWalker.Cli/Json/StatResult.cs @@ -22,11 +22,20 @@ internal sealed record ExtensionStat [JsonPropertyName("avgSize")] public required long AvgSize { get; init; } + [JsonPropertyName("avgSizeFormatted")] + public required string AvgSizeFormatted { get; init; } + [JsonPropertyName("minSize")] public required long MinSize { get; init; } + [JsonPropertyName("minSizeFormatted")] + public required string MinSizeFormatted { get; init; } + [JsonPropertyName("maxSize")] public required long MaxSize { get; init; } + + [JsonPropertyName("maxSizeFormatted")] + public required string MaxSizeFormatted { get; init; } } [ExcludeFromCodeCoverage] @@ -53,9 +62,15 @@ internal sealed record StatResult : BaseResult [JsonPropertyName("compressedSize")] public required long CompressedSize { get; init; } + [JsonPropertyName("compressedSizeFormatted")] + public required string CompressedSizeFormatted { get; init; } + [JsonPropertyName("uncompressedSize")] public required long UncompressedSize { get; init; } + [JsonPropertyName("uncompressedSizeFormatted")] + public required string UncompressedSizeFormatted { get; init; } + [JsonPropertyName("compressionRatio")] public required double CompressionRatio { get; init; } diff --git a/CodeWalker.Cli/RpfService.cs b/CodeWalker.Cli/RpfService.cs index e577cacae..69455ef10 100644 --- a/CodeWalker.Cli/RpfService.cs +++ b/CodeWalker.Cli/RpfService.cs @@ -113,9 +113,7 @@ private static void CollectFilesRecursive( if (recursive && rpf.Children != null) { foreach (RpfFile child in rpf.Children) - { CollectFilesRecursive(child, filters, recursive, files); - } } } @@ -142,9 +140,7 @@ entry is RpfFileEntry if (recursive && rpf.Children != null) { foreach (RpfFile child in rpf.Children) - { CountNonRpfFilesRecursive(child, recursive, ref count); - } } } diff --git a/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs index 129be77be..f04b9d325 100644 --- a/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs @@ -1,383 +1,449 @@ using System; using System.IO; +using System.Text.Json; +using System.Threading; using CodeWalker.Cli.Handlers; +using CodeWalker.GameFiles; using Xunit; namespace CodeWalker.Cli.Tests.Handlers; -[Collection("ConsoleOutput")] -public sealed class HashHandlerTests +// ── ParseEncoding ──────────────────────────────────────────────────── + +public sealed class ParseEncodingTests { - // ── Encoding validation ─────────────────────────────────────────── + [Theory] + [InlineData("UTF-8", JenkHashInputEncoding.UTF8)] + [InlineData("utf-8", JenkHashInputEncoding.UTF8)] + [InlineData("Utf-8", JenkHashInputEncoding.UTF8)] + [InlineData("ASCII", JenkHashInputEncoding.ASCII)] + [InlineData("ascii", JenkHashInputEncoding.ASCII)] + [InlineData("Ascii", JenkHashInputEncoding.ASCII)] + public void ValidEncoding_ReturnsExpected(string input, JenkHashInputEncoding expected) => + Assert.Equal(expected, HashHandler.ParseEncoding(input)); + + [Theory] + [InlineData("utf8")] + [InlineData("latin-1")] + [InlineData("")] + [InlineData("UTF8")] + public void InvalidEncoding_ThrowsArgumentException(string input) + { + ArgumentException ex = Assert.Throws( + () => HashHandler.ParseEncoding(input) + ); + Assert.Contains("Unknown encoding", ex.Message); + Assert.Contains(input, ex.Message); + } +} + +// ── ErrorResult ────────────────────────────────────────────────────── +public sealed class ErrorResultTests +{ [Fact] - public void Execute_UnknownEncoding_ReturnsOne() + public void ErrorResult_SetsSuccessFalse() { - TextWriter origOut = Console.Out; - TextWriter origErr = Console.Error; - try - { - Console.SetOut(new StringWriter()); - StringWriter stderr = new(); - Console.SetError(stderr); - - int exitCode = HashHandler.Execute(new HashOptions - { - Inputs = ["test"], - Encoding = "unknown-codec", - Json = false, - }, TestContext.Current.CancellationToken); - - Assert.Equal(1, exitCode); - Assert.Contains("Unknown encoding", stderr.ToString()); - } - finally - { - Console.SetOut(origOut); - Console.SetError(origErr); - } + Json.HashResult result = HashHandler.ErrorResult([]); + Assert.False(result.Success); + Assert.Empty(result.Hashes); } [Fact] - public void Execute_UnknownEncoding_Json_ReturnsErrorJson() + public void ErrorResult_PreservesErrorMessages() + { + string[] msgs = ["err1", "err2"]; + Json.HashResult result = HashHandler.ErrorResult(msgs); + Assert.Equal(msgs, result.ErrorMessages); + } +} + +// ── PrintHashes ────────────────────────────────────────────────────── + +[Collection("ConsoleOutput")] +public sealed class PrintHashesTests +{ + private static string Capture(string[] inputs, JenkHashInputEncoding encoding) { - TextWriter origOut = Console.Out; + TextWriter orig = Console.Out; try { - StringWriter stdout = new(); - Console.SetOut(stdout); - - int exitCode = HashHandler.Execute(new HashOptions - { - Inputs = ["test"], - Encoding = "bad", - Json = true, - }, TestContext.Current.CancellationToken); - - Assert.Equal(1, exitCode); - string output = stdout.ToString(); - Assert.Contains("\"success\": false", output); - Assert.Contains("Unknown encoding", output); + StringWriter sw = new(); + Console.SetOut(sw); + Json.HashEntry[] hashes = HashHandler.CollectHashes(inputs, encoding, CancellationToken.None); + HashHandler.PrintHashes(hashes, TestContext.Current.CancellationToken); + return sw.ToString(); } - finally { Console.SetOut(origOut); } + finally { Console.SetOut(orig); } } - // ── Successful hashing ──────────────────────────────────────────── + [Fact] + public void SingleInput_PrintsAllFourLines() + { + string output = Capture(["test"], JenkHashInputEncoding.UTF8); + Assert.Contains("Input (UTF8): test", output); + Assert.Contains("Hash (uint):", output); + Assert.Contains("Hash (int):", output); + Assert.Contains("Hash (hex):", output); + } [Fact] - public void Execute_Utf8Encoding_ReturnsZero() + public void SingleInput_MatchesJenkHash() { - TextWriter origOut = Console.Out; - try - { - StringWriter stdout = new(); - Console.SetOut(stdout); - - int exitCode = HashHandler.Execute(new HashOptions - { - Inputs = ["test"], - Encoding = "utf8", - Json = false, - }, TestContext.Current.CancellationToken); - - Assert.Equal(0, exitCode); - string output = stdout.ToString(); - Assert.Contains("Input: test", output); - Assert.Contains("Hash (uint):", output); - Assert.Contains("Hash (hex):", output); - } - finally { Console.SetOut(origOut); } + JenkHash expected = new("test", JenkHashInputEncoding.UTF8); + string output = Capture(["test"], JenkHashInputEncoding.UTF8); + Assert.Contains($"Hash (uint): {expected.HashUint}", output); + Assert.Contains($"Hash (int): {expected.HashInt}", output); + Assert.Contains($"Hash (hex): {expected.HashHex}", output); } [Fact] - public void Execute_Utf8WithDash_ReturnsZero() + public void AsciiEncoding_ShowsAsciiInHeader() { - TextWriter origOut = Console.Out; - try - { - Console.SetOut(new StringWriter()); + string output = Capture(["hello"], JenkHashInputEncoding.ASCII); + Assert.Contains("Input (ASCII): hello", output); + } - int exitCode = HashHandler.Execute(new HashOptions - { - Inputs = ["test"], - Encoding = "utf-8", - Json = false, - }, TestContext.Current.CancellationToken); + [Fact] + public void MultipleInputs_PrintsEach() + { + string output = Capture(["alpha", "bravo"], JenkHashInputEncoding.UTF8); + Assert.Contains("Input (UTF8): alpha", output); + Assert.Contains("Input (UTF8): bravo", output); + } - Assert.Equal(0, exitCode); - } - finally { Console.SetOut(origOut); } + [Fact] + public void EmptyString_Succeeds() + { + string output = Capture([""], JenkHashInputEncoding.UTF8); + Assert.Contains("Input (UTF8): ", output); + Assert.Contains("Hash (uint):", output); } [Fact] - public void Execute_AsciiEncoding_ReturnsZero() + public void Cancelled_ThrowsOperationCanceledException() { - TextWriter origOut = Console.Out; + using CancellationTokenSource cts = new(); + cts.Cancel(); + + Json.HashEntry[] hashes = HashHandler.CollectHashes( + ["test"], JenkHashInputEncoding.UTF8, CancellationToken.None + ); + + TextWriter orig = Console.Out; try { Console.SetOut(new StringWriter()); + _ = Assert.Throws( + () => HashHandler.PrintHashes(hashes, cts.Token) + ); + } + finally { Console.SetOut(orig); } + } +} - int exitCode = HashHandler.Execute(new HashOptions - { - Inputs = ["hello"], - Encoding = "ascii", - Json = false, - }, TestContext.Current.CancellationToken); +// ── PrintJsonHashes ───────────────────────────────────────────────── - Assert.Equal(0, exitCode); +[Collection("ConsoleOutput")] +public sealed class PrintJsonHashesTests +{ + private static string Capture(string[] inputs, JenkHashInputEncoding encoding) + { + TextWriter orig = Console.Out; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + Json.HashEntry[] hashes = HashHandler.CollectHashes(inputs, encoding, CancellationToken.None); + HashHandler.PrintJsonHashes(hashes); + return sw.ToString(); } - finally { Console.SetOut(origOut); } + finally { Console.SetOut(orig); } } [Fact] - public void Execute_EncodingIsCaseInsensitive() + public void SingleInput_WritesValidJson() { - TextWriter origOut = Console.Out; - try - { - Console.SetOut(new StringWriter()); + Json.HashResult? result = JsonSerializer.Deserialize( + Capture(["test"], JenkHashInputEncoding.UTF8).Trim(), + RpfService.JsonSerializerOptions + ); + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Empty(result.ErrorMessages); + _ = Assert.Single(result.Hashes); + } - int exitCode = HashHandler.Execute(new HashOptions - { - Inputs = ["test"], - Encoding = "ASCII", - Json = false, - }, TestContext.Current.CancellationToken); + [Fact] + public void SingleInput_MatchesJenkHash() + { + JenkHash expected = new("test", JenkHashInputEncoding.UTF8); + Json.HashResult? result = JsonSerializer.Deserialize( + Capture(["test"], JenkHashInputEncoding.UTF8).Trim(), + RpfService.JsonSerializerOptions + ); + Assert.NotNull(result); + + Json.HashEntry entry = result.Hashes[0]; + Assert.Equal("test", entry.Input); + Assert.Equal(expected.HashUint, entry.Hash); + Assert.Equal(expected.HashInt, entry.HashSigned); + Assert.Equal(expected.HashHex, entry.HashHex); + Assert.Equal("UTF8", entry.Encoding); + } - Assert.Equal(0, exitCode); - } - finally { Console.SetOut(origOut); } + [Fact] + public void AsciiEncoding_SetsEncodingField() + { + Json.HashResult? result = JsonSerializer.Deserialize( + Capture(["hello"], JenkHashInputEncoding.ASCII).Trim(), + RpfService.JsonSerializerOptions + ); + Assert.NotNull(result); + Assert.Equal("ASCII", result.Hashes[0].Encoding); } - // ── Multiple inputs ─────────────────────────────────────────────── + [Fact] + public void MultipleInputs_ReturnsAll() + { + Json.HashResult? result = JsonSerializer.Deserialize( + Capture(["alpha", "bravo"], JenkHashInputEncoding.UTF8).Trim(), + RpfService.JsonSerializerOptions + ); + Assert.NotNull(result); + Assert.Equal(2, result.Hashes.Count); + Assert.Equal("alpha", result.Hashes[0].Input); + Assert.Equal("bravo", result.Hashes[1].Input); + } + +} + +// ── CollectHashes ──────────────────────────────────────────────────── +public sealed class CollectHashesTests +{ [Fact] - public void Execute_MultipleInputs_HashesAll() + public void SingleInput_ReturnsOneEntry() { - TextWriter origOut = Console.Out; - try - { - StringWriter stdout = new(); - Console.SetOut(stdout); - - int exitCode = HashHandler.Execute(new HashOptions - { - Inputs = ["alpha", "bravo", "charlie"], - Encoding = "utf8", - Json = false, - }, TestContext.Current.CancellationToken); - - Assert.Equal(0, exitCode); - string output = stdout.ToString(); - Assert.Contains("Input: alpha", output); - Assert.Contains("Input: bravo", output); - Assert.Contains("Input: charlie", output); - } - finally { Console.SetOut(origOut); } + Json.HashEntry[] entries = HashHandler.CollectHashes( + ["vehicle"], + JenkHashInputEncoding.UTF8, + TestContext.Current.CancellationToken + ); + _ = Assert.Single(entries); + } + + [Fact] + public void SingleInput_MatchesJenkHash() + { + JenkHash expected = new("vehicle", JenkHashInputEncoding.UTF8); + Json.HashEntry[] entries = HashHandler.CollectHashes( + ["vehicle"], + JenkHashInputEncoding.UTF8, + TestContext.Current.CancellationToken + ); + + Json.HashEntry entry = entries[0]; + Assert.Equal("vehicle", entry.Input); + Assert.Equal(expected.HashUint, entry.Hash); + Assert.Equal(expected.HashInt, entry.HashSigned); + Assert.Equal(expected.HashHex, entry.HashHex); + Assert.Equal("UTF8", entry.Encoding); } - // ── JSON output ─────────────────────────────────────────────────── + [Fact] + public void AsciiEncoding_SetsEncodingField() + { + Json.HashEntry[] entries = HashHandler.CollectHashes( + ["test"], + JenkHashInputEncoding.ASCII, + TestContext.Current.CancellationToken + ); + Assert.Equal("ASCII", entries[0].Encoding); + } [Fact] - public void Execute_JsonMode_ContainsExpectedFields() + public void MultipleInputs_ReturnsAll() { - TextWriter origOut = Console.Out; - try - { - StringWriter stdout = new(); - Console.SetOut(stdout); - - int exitCode = HashHandler.Execute(new HashOptions - { - Inputs = ["vehicle"], - Encoding = "utf8", - Json = true, - }, TestContext.Current.CancellationToken); - - Assert.Equal(0, exitCode); - string output = stdout.ToString(); - Assert.Contains("\"success\": true", output); - Assert.Contains("\"input\": \"vehicle\"", output); - Assert.Contains("\"hash\":", output); - Assert.Contains("\"hashHex\":", output); - Assert.Contains("\"encoding\":", output); - } - finally { Console.SetOut(origOut); } + Json.HashEntry[] entries = HashHandler.CollectHashes( + ["one", "two", "three"], + JenkHashInputEncoding.UTF8, + TestContext.Current.CancellationToken + ); + Assert.Equal(3, entries.Length); + Assert.Equal("one", entries[0].Input); + Assert.Equal("two", entries[1].Input); + Assert.Equal("three", entries[2].Input); } [Fact] - public void Execute_JsonMode_MultipleInputs_HasMultipleEntries() + public void DifferentInputs_ProduceDifferentHashes() { - TextWriter origOut = Console.Out; - try - { - StringWriter stdout = new(); - Console.SetOut(stdout); - - int exitCode = HashHandler.Execute(new HashOptions - { - Inputs = ["one", "two"], - Encoding = "utf8", - Json = true, - }, TestContext.Current.CancellationToken); - - Assert.Equal(0, exitCode); - string output = stdout.ToString(); - Assert.Contains("\"input\": \"one\"", output); - Assert.Contains("\"input\": \"two\"", output); - } - finally { Console.SetOut(origOut); } + Json.HashEntry[] entries = HashHandler.CollectHashes( + ["alpha", "beta"], + JenkHashInputEncoding.UTF8, + TestContext.Current.CancellationToken + ); + Assert.NotEqual(entries[0].Hash, entries[1].Hash); } - // ── Deterministic hashes ────────────────────────────────────────── + [Fact] + public void SameInput_ProducesSameHash() + { + Json.HashEntry[] a = HashHandler.CollectHashes( + ["deterministic"], + JenkHashInputEncoding.UTF8, + TestContext.Current.CancellationToken + ); + Json.HashEntry[] b = HashHandler.CollectHashes( + ["deterministic"], + JenkHashInputEncoding.UTF8, + TestContext.Current.CancellationToken + ); + Assert.Equal(a[0].Hash, b[0].Hash); + } [Fact] - public void Execute_SameInput_ProducesSameHash() + public void Cancelled_ThrowsOperationCanceledException() { - TextWriter origOut = Console.Out; + using CancellationTokenSource cts = new(); + cts.Cancel(); + _ = Assert.Throws( + () => HashHandler.CollectHashes(["test"], + JenkHashInputEncoding.UTF8, + cts.Token + ) + ); + } +} + +// ── Execute (integration) ──────────────────────────────────────────── + +[Collection("ConsoleOutput")] +public sealed class HashExecuteTests +{ + private static HashOptions MakeOptions( + string[] inputs, + string encoding = HashOptions.DefaultEncoding, + bool json = false + ) => new() { Inputs = inputs, Encoding = encoding, Json = json }; + + [Fact] + public void Text_ReturnsZero() + { + TextWriter orig = Console.Out; try { - StringWriter stdout1 = new(); - Console.SetOut(stdout1); - _ = HashHandler.Execute(new HashOptions - { - Inputs = ["deterministic"], - Encoding = "utf8", - Json = true, - }, TestContext.Current.CancellationToken); - - StringWriter stdout2 = new(); - Console.SetOut(stdout2); - _ = HashHandler.Execute(new HashOptions - { - Inputs = ["deterministic"], - Encoding = "utf8", - Json = true, - }, TestContext.Current.CancellationToken); - - Assert.Equal(stdout1.ToString(), stdout2.ToString()); + Console.SetOut(new StringWriter()); + Assert.Equal(0, HashHandler.Execute( + MakeOptions(["test"]), + TestContext.Current.CancellationToken + )); } - finally { Console.SetOut(origOut); } + finally { Console.SetOut(orig); } } - // ── Edge cases ──────────────────────────────────────────────────── - [Fact] - public void Execute_EmptyStringInput_ReturnsZero() + public void Json_WritesValidJson() { - TextWriter origOut = Console.Out; + TextWriter orig = Console.Out; try { - StringWriter stdout = new(); - Console.SetOut(stdout); - - int exitCode = HashHandler.Execute(new HashOptions - { - Inputs = [""], - Encoding = "utf8", - Json = false, - }, TestContext.Current.CancellationToken); - - Assert.Equal(0, exitCode); - Assert.Contains("Input: ", stdout.ToString()); + StringWriter sw = new(); + Console.SetOut(sw); + Assert.Equal(0, HashHandler.Execute( + MakeOptions(["test"], json: true), + TestContext.Current.CancellationToken + )); + + Json.HashResult? result = JsonSerializer.Deserialize( + sw.ToString().Trim(), RpfService.JsonSerializerOptions + ); + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Empty(result.ErrorMessages); + _ = Assert.Single(result.Hashes); } - finally { Console.SetOut(origOut); } + finally { Console.SetOut(orig); } } [Fact] - public void Execute_CancellationToken_ThrowsOperationCanceled() + public void InvalidEncoding_PropagatesArgumentException() { - using System.Threading.CancellationTokenSource cts = new(); - cts.Cancel(); - - TextWriter origOut = Console.Out; + TextWriter orig = Console.Out; try { Console.SetOut(new StringWriter()); - _ = Assert.Throws(() => - HashHandler.Execute(new HashOptions - { - Inputs = ["test"], - Encoding = "utf8", - Json = false, - }, cts.Token) + _ = Assert.Throws( + () => HashHandler.Execute( + MakeOptions(["test"], encoding: "bad"), + TestContext.Current.CancellationToken + ) ); } - finally { Console.SetOut(origOut); } + finally { Console.SetOut(orig); } } [Fact] - public void Execute_DifferentInputs_ProduceDifferentHashes() + public void Json_InvalidEncoding_PropagatesArgumentException() { - TextWriter origOut = Console.Out; + TextWriter orig = Console.Out; try { - StringWriter stdout1 = new(); - Console.SetOut(stdout1); - _ = HashHandler.Execute(new HashOptions - { - Inputs = ["alpha"], - Encoding = "utf8", - Json = true, - }, TestContext.Current.CancellationToken); - - StringWriter stdout2 = new(); - Console.SetOut(stdout2); - _ = HashHandler.Execute(new HashOptions - { - Inputs = ["beta"], - Encoding = "utf8", - Json = true, - }, TestContext.Current.CancellationToken); - - Assert.NotEqual(stdout1.ToString(), stdout2.ToString()); + Console.SetOut(new StringWriter()); + _ = Assert.Throws( + () => HashHandler.Execute( + MakeOptions(["test"], encoding: "bad", json: true), + TestContext.Current.CancellationToken + ) + ); } - finally { Console.SetOut(origOut); } + finally { Console.SetOut(orig); } } [Fact] - public void Execute_Json_ContainsHashSigned() + public void DefaultEncoding_IsUtf8() => + Assert.Equal("UTF-8", HashOptions.DefaultEncoding); + + [Fact] + public void Text_Cancelled_ThrowsOperationCanceledException() { - TextWriter origOut = Console.Out; + using CancellationTokenSource cts = new(); + cts.Cancel(); + + TextWriter orig = Console.Out; try { - StringWriter stdout = new(); - Console.SetOut(stdout); - - _ = HashHandler.Execute(new HashOptions - { - Inputs = ["test"], - Encoding = "utf8", - Json = true, - }, TestContext.Current.CancellationToken); - - string output = stdout.ToString(); - Assert.Contains("\"hashSigned\":", output); + Console.SetOut(new StringWriter()); + _ = Assert.Throws( + () => HashHandler.Execute( + MakeOptions(["test"]), + cts.Token + ) + ); } - finally { Console.SetOut(origOut); } + finally { Console.SetOut(orig); } } [Fact] - public void Execute_TextMode_ShowsIntHash() + public void Json_Cancelled_ThrowsOperationCanceledException() { - TextWriter origOut = Console.Out; + using CancellationTokenSource cts = new(); + cts.Cancel(); + + TextWriter orig = Console.Out; try { - StringWriter stdout = new(); - Console.SetOut(stdout); - - _ = HashHandler.Execute(new HashOptions - { - Inputs = ["test"], - Encoding = "utf8", - Json = false, - }, TestContext.Current.CancellationToken); - - string output = stdout.ToString(); - Assert.Contains("Hash (int):", output); + Console.SetOut(new StringWriter()); + _ = Assert.Throws( + () => HashHandler.Execute( + MakeOptions(["test"], json: true), + cts.Token + ) + ); } - finally { Console.SetOut(origOut); } + finally { Console.SetOut(orig); } } } diff --git a/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs index f0221c3bc..57ccb5770 100644 --- a/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs @@ -1,15 +1,587 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Text.Json; +using System.Threading; using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; using Xunit; namespace CodeWalker.Cli.Tests.Handlers; +// ── ErrorResult ────────────────────────────────────────────────────── + +public sealed class StatErrorResultTests +{ + private static RpfOptions MakeOptions(string rpfPath = "/test.rpf") => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, + }; + + [Fact] + public void ErrorResult_SetsSuccessFalse() + { + Json.StatResult result = StatHandler.ErrorResult([], MakeOptions()); + Assert.False(result.Success); + } + + [Fact] + public void ErrorResult_PreservesErrorMessages() + { + string[] msgs = ["err1", "err2"]; + Json.StatResult result = StatHandler.ErrorResult(msgs, MakeOptions()); + Assert.Equal(msgs, result.ErrorMessages); + } + + [Fact] + public void ErrorResult_SetsRpfFile() + { + Json.StatResult result = StatHandler.ErrorResult([], MakeOptions("/my/test.rpf")); + Assert.Equal("/my/test.rpf", result.RpfFile); + } + + [Fact] + public void ErrorResult_AllStatsAreZero() + { + Json.StatResult result = StatHandler.ErrorResult([], MakeOptions()); + Assert.Equal(0, result.TotalFiles); + Assert.Equal(0, result.TotalSize); + Assert.Equal(0, result.ResourceCount); + Assert.Equal(0, result.BinaryCount); + Assert.Equal(0, result.CompressedSize); + Assert.Equal(0, result.UncompressedSize); + Assert.Equal(0, result.CompressionRatio); + } + + [Fact] + public void ErrorResult_FormattedSizesAreZeroB() + { + Json.StatResult result = StatHandler.ErrorResult([], MakeOptions()); + Assert.Equal("0 B", result.TotalSizeFormatted); + Assert.Equal("0 B", result.CompressedSizeFormatted); + Assert.Equal("0 B", result.UncompressedSizeFormatted); + } + + [Fact] + public void ErrorResult_ExtensionsAreEmpty() + { + Json.StatResult result = StatHandler.ErrorResult([], MakeOptions()); + Assert.Empty(result.Extensions); + } +} + +// ── CollectStats ───────────────────────────────────────────────────── + +public sealed class CollectStatsTests +{ + private static RpfOptions MakeOptions(SizeFormat fmt = SizeFormat.IEC) => + new() + { + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + Threads = 1, + SizeFormat = fmt, + }; + + private static RpfBinaryFileEntry MakeBinary(string name, uint fileSize, uint uncompressedSize) => + new() { Name = name, FileSize = fileSize, FileUncompressedSize = uncompressedSize }; + + private static RpfResourceFileEntry MakeResource(string name, uint fileSize, uint sysFlags, uint gfxFlags) => + new() + { + Name = name, + FileSize = fileSize, + SystemFlags = new RpfResourcePageFlags(sysFlags), + GraphicsFlags = new RpfResourcePageFlags(gfxFlags), + }; + + [Fact] + public void EmptyEntries_ReturnsAllZeros() + { + Json.StatResult result = StatHandler.CollectStats([], [], MakeOptions(), TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(0, result.TotalFiles); + Assert.Equal(0, result.TotalSize); + Assert.Equal(0, result.ResourceCount); + Assert.Equal(0, result.BinaryCount); + Assert.Equal(0, result.CompressedSize); + Assert.Equal(0, result.UncompressedSize); + Assert.Equal(0, result.CompressionRatio); + Assert.Empty(result.Extensions); + } + + [Fact] + public void SingleBinary_CountsCorrectly() + { + RpfBinaryFileEntry entry = MakeBinary("data.dat", fileSize: 200, uncompressedSize: 400); + List<(RpfFile, RpfFileEntry)> entries = [(null!, entry)]; + + Json.StatResult result = StatHandler.CollectStats(entries, [], MakeOptions(), TestContext.Current.CancellationToken); + + Assert.Equal(1, result.TotalFiles); + Assert.Equal(200, result.TotalSize); // GetFileSize() returns FileSize when non-zero + Assert.Equal(0, result.ResourceCount); + Assert.Equal(1, result.BinaryCount); + Assert.Equal(200, result.CompressedSize); + Assert.Equal(400, result.UncompressedSize); + } + + [Fact] + public void SingleResource_CountsCorrectly() + { + // 0x08000000 → SystemFlags.Size = 512, 0x04000000 → GraphicsFlags.Size = 1024 + RpfResourceFileEntry entry = MakeResource("model.ydr", fileSize: 300, sysFlags: 0x08000000, gfxFlags: 0x04000000); + List<(RpfFile, RpfFileEntry)> entries = [(null!, entry)]; + + Json.StatResult result = StatHandler.CollectStats(entries, [], MakeOptions(), TestContext.Current.CancellationToken); + + Assert.Equal(1, result.TotalFiles); + Assert.Equal(300, result.TotalSize); // GetFileSize() returns FileSize when non-zero + Assert.Equal(1, result.ResourceCount); + Assert.Equal(0, result.BinaryCount); + Assert.Equal(300, result.CompressedSize); + Assert.Equal(512 + 1024, result.UncompressedSize); + } + + [Fact] + public void MixedEntries_AggregatesCorrectly() + { + RpfBinaryFileEntry bin = MakeBinary("data.dat", fileSize: 200, uncompressedSize: 400); + RpfResourceFileEntry res = MakeResource("model.ydr", fileSize: 300, sysFlags: 0x08000000, gfxFlags: 0x04000000); + List<(RpfFile, RpfFileEntry)> entries = [(null!, bin), (null!, res)]; + + Json.StatResult result = StatHandler.CollectStats(entries, [], MakeOptions(), TestContext.Current.CancellationToken); + + Assert.Equal(2, result.TotalFiles); + Assert.Equal(200 + 300, result.TotalSize); + Assert.Equal(1, result.ResourceCount); + Assert.Equal(1, result.BinaryCount); + Assert.Equal(200 + 300, result.CompressedSize); + Assert.Equal(400 + 512 + 1024, result.UncompressedSize); + } + + [Fact] + public void CompressionRatio_CalculatedCorrectly() + { + RpfBinaryFileEntry entry = MakeBinary("data.dat", fileSize: 250, uncompressedSize: 1000); + List<(RpfFile, RpfFileEntry)> entries = [(null!, entry)]; + + Json.StatResult result = StatHandler.CollectStats(entries, [], MakeOptions(), TestContext.Current.CancellationToken); + + Assert.Equal(0.25, result.CompressionRatio); + } + + [Fact] + public void CompressionRatio_ZeroWhenNoUncompressed() + { + // Entry with FileSize=0 and FileUncompressedSize=0 → GetFileSize() returns 0 + RpfBinaryFileEntry entry = MakeBinary("empty.dat", fileSize: 0, uncompressedSize: 0); + List<(RpfFile, RpfFileEntry)> entries = [(null!, entry)]; + + Json.StatResult result = StatHandler.CollectStats(entries, [], MakeOptions(), TestContext.Current.CancellationToken); + + Assert.Equal(0, result.CompressionRatio); + } + + [Fact] + public void ExtensionStats_GroupedAndSorted() + { + RpfBinaryFileEntry small = MakeBinary("a.dat", fileSize: 100, uncompressedSize: 100); + RpfBinaryFileEntry large1 = MakeBinary("b.ydr", fileSize: 500, uncompressedSize: 500); + RpfBinaryFileEntry large2 = MakeBinary("c.ydr", fileSize: 600, uncompressedSize: 600); + List<(RpfFile, RpfFileEntry)> entries = [(null!, small), (null!, large1), (null!, large2)]; + + Json.StatResult result = StatHandler.CollectStats(entries, [], MakeOptions(), TestContext.Current.CancellationToken); + + Assert.Equal(2, result.Extensions.Count); + // .ydr total (1100) > .dat total (100), so .ydr comes first + Assert.Equal(".ydr", result.Extensions[0].Extension); + Assert.Equal(2, result.Extensions[0].Count); + Assert.Equal(1100, result.Extensions[0].TotalSize); + Assert.Equal(".dat", result.Extensions[1].Extension); + Assert.Equal(1, result.Extensions[1].Count); + Assert.Equal(100, result.Extensions[1].TotalSize); + } + + [Fact] + public void ExtensionStats_MinMaxAvg() + { + RpfBinaryFileEntry a = MakeBinary("a.dat", fileSize: 100, uncompressedSize: 100); + RpfBinaryFileEntry b = MakeBinary("b.dat", fileSize: 300, uncompressedSize: 300); + List<(RpfFile, RpfFileEntry)> entries = [(null!, a), (null!, b)]; + + Json.StatResult result = StatHandler.CollectStats(entries, [], MakeOptions(), TestContext.Current.CancellationToken); + + Json.ExtensionStat ext = result.Extensions[0]; + Assert.Equal(".dat", ext.Extension); + Assert.Equal(100, ext.MinSize); + Assert.Equal(300, ext.MaxSize); + Assert.Equal(200, ext.AvgSize); // (100 + 300) / 2 + } + + [Fact] + public void NoExtension_CategorizedAsNone() + { + RpfBinaryFileEntry entry = MakeBinary("README", fileSize: 50, uncompressedSize: 50); + List<(RpfFile, RpfFileEntry)> entries = [(null!, entry)]; + + Json.StatResult result = StatHandler.CollectStats(entries, [], MakeOptions(), TestContext.Current.CancellationToken); + + Assert.Equal("(none)", result.Extensions[0].Extension); + } + + [Fact] + public void ScanErrors_SetSuccessFalse() + { + List errors = ["scan failed"]; + Json.StatResult result = StatHandler.CollectStats([], errors, MakeOptions(), TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Equal(["scan failed"], result.ErrorMessages); + } + + [Fact] + public void ScanErrors_Empty_SetSuccessTrue() + { + Json.StatResult result = StatHandler.CollectStats([], [], MakeOptions(), TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Empty(result.ErrorMessages); + } + + [Fact] + public void Cancelled_ThrowsOperationCanceledException() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + + RpfBinaryFileEntry entry = MakeBinary("data.dat", fileSize: 100, uncompressedSize: 100); + List<(RpfFile, RpfFileEntry)> entries = [(null!, entry)]; + + _ = Assert.Throws( + () => StatHandler.CollectStats(entries, [], MakeOptions(), cts.Token) + ); + } +} + +// ── PrintJsonStats ─────────────────────────────────────────────────── + +[Collection("ConsoleOutput")] +public sealed class PrintJsonStatsTests +{ + private static Json.StatResult MakeResult( + bool success = true, + int totalFiles = 5, + long totalSize = 1024, + int resourceCount = 3, + int binaryCount = 2, + long compressedSize = 800, + long uncompressedSize = 1200, + double compressionRatio = 0.6667) => + new() + { + Success = success, + RpfFile = "/test.rpf", + TotalFiles = totalFiles, + TotalSize = totalSize, + TotalSizeFormatted = SizeFormat.IEC.ToFormattedString(totalSize), + ResourceCount = resourceCount, + BinaryCount = binaryCount, + CompressedSize = compressedSize, + CompressedSizeFormatted = SizeFormat.IEC.ToFormattedString(compressedSize), + UncompressedSize = uncompressedSize, + UncompressedSizeFormatted = SizeFormat.IEC.ToFormattedString(uncompressedSize), + CompressionRatio = compressionRatio, + Extensions = + [ + new Json.ExtensionStat + { + Extension = ".dat", + Count = 2, + TotalSize = 500, + TotalSizeFormatted = "500 B", + AvgSize = 250, + AvgSizeFormatted = "250 B", + MinSize = 200, + MinSizeFormatted = "200 B", + MaxSize = 300, + MaxSizeFormatted = "300 B", + }, + ], + ErrorMessages = [], + }; + + [Fact] + public void PrintJsonStats_WritesValidJson() + { + TextWriter orig = Console.Out; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + + StatHandler.PrintJsonStats(MakeResult()); + + Json.StatResult? parsed = JsonSerializer.Deserialize( + sw.ToString().Trim(), RpfService.JsonSerializerOptions + ); + Assert.NotNull(parsed); + } + finally { Console.SetOut(orig); } + } + + [Fact] + public void PrintJsonStats_ContainsAllFields() + { + TextWriter orig = Console.Out; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + + StatHandler.PrintJsonStats(MakeResult()); + + string json = sw.ToString(); + Assert.Contains("\"success\": true", json); + Assert.Contains("\"rpfFile\": \"/test.rpf\"", json); + Assert.Contains("\"totalFiles\": 5", json); + Assert.Contains("\"totalSize\": 1024", json); + Assert.Contains("\"totalSizeFormatted\":", json); + Assert.Contains("\"resourceCount\": 3", json); + Assert.Contains("\"binaryCount\": 2", json); + Assert.Contains("\"compressedSize\": 800", json); + Assert.Contains("\"compressedSizeFormatted\":", json); + Assert.Contains("\"uncompressedSize\": 1200", json); + Assert.Contains("\"uncompressedSizeFormatted\":", json); + Assert.Contains("\"compressionRatio\": 0.6667", json); + Assert.Contains("\"extensions\":", json); + } + finally { Console.SetOut(orig); } + } + + [Fact] + public void PrintJsonStats_ExtensionStatFields() + { + TextWriter orig = Console.Out; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + + StatHandler.PrintJsonStats(MakeResult()); + + string json = sw.ToString(); + Assert.Contains("\"extension\": \".dat\"", json); + Assert.Contains("\"count\": 2", json); + Assert.Contains("\"totalSize\": 500", json); + Assert.Contains("\"avgSize\": 250", json); + Assert.Contains("\"minSize\": 200", json); + Assert.Contains("\"maxSize\": 300", json); + } + finally { Console.SetOut(orig); } + } + + [Fact] + public void PrintJsonStats_ErrorResult_ShowsSuccessFalse() + { + TextWriter orig = Console.Out; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + + StatHandler.PrintJsonStats(MakeResult(success: false)); + + Assert.Contains("\"success\": false", sw.ToString()); + } + finally { Console.SetOut(orig); } + } + + [Fact] + public void PrintJsonStats_RoundTripsCorrectly() + { + TextWriter orig = Console.Out; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + + Json.StatResult input = MakeResult(); + StatHandler.PrintJsonStats(input); + + Json.StatResult? parsed = JsonSerializer.Deserialize( + sw.ToString().Trim(), RpfService.JsonSerializerOptions + ); + Assert.NotNull(parsed); + Assert.Equal(input.TotalFiles, parsed.TotalFiles); + Assert.Equal(input.TotalSize, parsed.TotalSize); + Assert.Equal(input.ResourceCount, parsed.ResourceCount); + Assert.Equal(input.BinaryCount, parsed.BinaryCount); + Assert.Equal(input.CompressedSize, parsed.CompressedSize); + Assert.Equal(input.UncompressedSize, parsed.UncompressedSize); + Assert.Equal(input.CompressionRatio, parsed.CompressionRatio); + Assert.Equal(input.Extensions.Count, parsed.Extensions.Count); + } + finally { Console.SetOut(orig); } + } +} + +// ── PrintStats (text) ──────────────────────────────────────────────── + +[Collection("ConsoleOutput")] +public sealed class PrintStatsTests +{ + private static RpfOptions MakeOptions(SizeFormat fmt = SizeFormat.IEC) => + new() + { + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + Threads = 1, + SizeFormat = fmt, + }; + + private static (string stdout, string stderr) Capture(Json.StatResult result, RpfOptions? options = null) + { + options ??= MakeOptions(); + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter sw = new(); + StringWriter se = new(); + Console.SetOut(sw); + Console.SetError(se); + StatHandler.PrintStats(result, options); + return (sw.ToString(), se.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + private static Json.StatResult MakeResult(IReadOnlyList? extensions = null) => + new() + { + Success = true, + RpfFile = "/test.rpf", + TotalFiles = 10, + TotalSize = 2048, + TotalSizeFormatted = "2.0 KiB", + ResourceCount = 6, + BinaryCount = 4, + CompressedSize = 1500, + CompressedSizeFormatted = "1.5 KiB", + UncompressedSize = 3000, + UncompressedSizeFormatted = "2.9 KiB", + CompressionRatio = 0.5, + Extensions = extensions ?? + [ + new Json.ExtensionStat + { + Extension = ".ydr", + Count = 3, + TotalSize = 1500, + TotalSizeFormatted = "1.5 KiB", + AvgSize = 500, + AvgSizeFormatted = "500 B", + MinSize = 200, + MinSizeFormatted = "200 B", + MaxSize = 800, + MaxSizeFormatted = "800 B", + }, + ], + ErrorMessages = [], + }; + + [Fact] + public void PrintStats_TableHasHeaders() + { + (string stdout, _) = Capture(MakeResult()); + Assert.Contains("Extension", stdout); + Assert.Contains("Count", stdout); + Assert.Contains("Total", stdout); + Assert.Contains("Avg", stdout); + Assert.Contains("Min", stdout); + Assert.Contains("Max", stdout); + } + + [Fact] + public void PrintStats_TableHasSeparator() + { + (string stdout, _) = Capture(MakeResult()); + Assert.Contains("---", stdout); + Assert.Contains("+", stdout); + } + + [Fact] + public void PrintStats_TableHasExtensionRow() + { + (string stdout, _) = Capture(MakeResult()); + Assert.Contains(".ydr", stdout); + } + + [Fact] + public void PrintStats_StderrHasSummary() + { + (_, string stderr) = Capture(MakeResult()); + Assert.Contains("Total: 10 files", stderr); + Assert.Contains("Types: 6 resource, 4 binary", stderr); + } + + [Fact] + public void PrintStats_StderrHasCompression() + { + (_, string stderr) = Capture(MakeResult()); + Assert.Contains("Compression:", stderr); + } + + [Fact] + public void PrintStats_NoCompression_WhenUncompressedIsZero() + { + Json.StatResult result = MakeResult() with { UncompressedSize = 0 }; + (_, string stderr) = Capture(result); + Assert.DoesNotContain("Compression:", stderr); + } + + [Fact] + public void PrintStats_EmptyExtensions_PrintsHeaderOnly() + { + Json.StatResult result = MakeResult(extensions: []); + (string stdout, _) = Capture(result); + Assert.Contains("Extension", stdout); + Assert.DoesNotContain(".ydr", stdout); + } +} + +// ── Execute (integration) ──────────────────────────────────────────── + [Collection("ConsoleOutput")] -public sealed class StatHandlerTests +public sealed class StatExecuteTests { private static RpfOptions MakeOptions(string rpfPath, bool json) => new() @@ -76,7 +648,7 @@ public void Execute_MissingRpf_Json_ReturnsErrorJson() } [Fact] - public void Execute_Json_ErrorContainsExpectedFields() + public void Execute_Json_ErrorContainsAllExpectedFields() { TextWriter origOut = Console.Out; try @@ -90,9 +662,15 @@ public void Execute_Json_ErrorContainsExpectedFields() Assert.Contains("\"rpfFile\":", output); Assert.Contains("\"totalFiles\": 0", output); Assert.Contains("\"totalSize\": 0", output); + Assert.Contains("\"totalSizeFormatted\":", output); Assert.Contains("\"resourceCount\": 0", output); Assert.Contains("\"binaryCount\": 0", output); + Assert.Contains("\"compressedSize\": 0", output); + Assert.Contains("\"compressedSizeFormatted\":", output); + Assert.Contains("\"uncompressedSize\": 0", output); + Assert.Contains("\"uncompressedSizeFormatted\":", output); Assert.Contains("\"compressionRatio\": 0", output); + Assert.Contains("\"extensions\": []", output); } finally { Console.SetOut(origOut); } } diff --git a/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs b/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs index c24052fdc..24d947e4f 100644 --- a/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs +++ b/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs @@ -236,7 +236,7 @@ public void Concurrent_increments_are_thread_safe() { const int total = 10_000; StringWriter sw = new(); - using ProgressBar bar = new(total, enabled: true, sw); + ProgressBar bar = new(total, enabled: true, sw); _ = Parallel.For(0, total, _ => bar.Increment()); diff --git a/CodeWalker.Cli/Tests/PolyfillsTests.cs b/CodeWalker.Cli/Tests/PolyfillsTests.cs index f16dcbb84..ecb7c621f 100644 --- a/CodeWalker.Cli/Tests/PolyfillsTests.cs +++ b/CodeWalker.Cli/Tests/PolyfillsTests.cs @@ -163,16 +163,12 @@ public void Replace_MatchesBuiltIn() // ─── Helpers ──────────────────────────────────────────────────── - private static void AssertBool(bool expected, bool actual, string label) - { + private static void AssertBool(bool expected, bool actual, string label) => Assert.True(expected == actual, $"{label}: expected={expected} actual={actual}"); - } - private static void AssertString(string expected, string actual, string label) - { + private static void AssertString(string expected, string actual, string label) => Assert.True(string.Equals(expected, actual, StringComparison.Ordinal), $"{label}: expected=\"{Esc(expected)}\" actual=\"{Esc(actual)}\""); - } private static string Esc(string? s) => s?.Replace("\0", "\\0", StringComparison.Ordinal) @@ -187,25 +183,28 @@ public sealed class StringExtensionsUnitTests [Fact] public void Replace_NullOldValue_Throws() { - _ = Assert.Throws(() => StringExtensions.Replace("input", null!, "new", StringComparison.Ordinal)); + _ = Assert.Throws(() => + StringExtensions.Replace("input", null!, "new", StringComparison.Ordinal)); } [Fact] public void Replace_EmptyOldValue_Throws() { - _ = Assert.Throws(() => StringExtensions.Replace("input", "", "new", StringComparison.Ordinal)); + _ = Assert.Throws(() => + StringExtensions.Replace("input", "", "new", StringComparison.Ordinal)); } [Fact] - public void Replace_NullNewValue_DoesNotThrow() + public void Replace_UnsupportedComparison_Throws() { - string result = StringExtensions.Replace("input", "in", null, StringComparison.Ordinal); - Assert.Equal("input".Replace("in", null), result); + _ = Assert.Throws(() => + StringExtensions.Replace("input", "in", "new", (StringComparison)999)); } [Fact] - public void Replace_UnsupportedComparison_Throws() + public void Replace_NullNewValue_DoesNotThrow() { - _ = Assert.Throws(() => StringExtensions.Replace("input", "in", "new", (StringComparison)999)); + string result = StringExtensions.Replace("input", "in", null, StringComparison.Ordinal); + Assert.Equal("input".Replace("in", null), result); } } From feb24ca748b43b7b72e51fdec454159376b612aa Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:06:50 +0200 Subject: [PATCH 22/45] fix(cli): list nested archives when not recursing CollectFiles dropped every entry whose name ends in .rpf regardless of --recursive. That is right while recursing, since the archive's contents are walked in its place, but without it the nested archives simply vanished from the listing and there was no way to see that they exist. --- CodeWalker.Cli/RpfService.cs | 2 +- CodeWalker.Cli/Tests/RpfServiceTests.cs | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CodeWalker.Cli/RpfService.cs b/CodeWalker.Cli/RpfService.cs index 69455ef10..7f229ca5d 100644 --- a/CodeWalker.Cli/RpfService.cs +++ b/CodeWalker.Cli/RpfService.cs @@ -104,7 +104,7 @@ private static void CollectFilesRecursive( rpf.AllEntries .OfType() .Where(fe => - !fe.NameLower.EndsWith(".rpf", StringComparison.Ordinal) + (!recursive || !fe.NameLower.EndsWith(".rpf", StringComparison.Ordinal)) && Filter.Matches(fe.Path, filters)) .Select(fe => (rpf, fe)) ); diff --git a/CodeWalker.Cli/Tests/RpfServiceTests.cs b/CodeWalker.Cli/Tests/RpfServiceTests.cs index 077b10c2e..31610e671 100644 --- a/CodeWalker.Cli/Tests/RpfServiceTests.cs +++ b/CodeWalker.Cli/Tests/RpfServiceTests.cs @@ -180,17 +180,30 @@ public void CollectFiles_ReturnsFileEntries() } [Fact] - public void CollectFiles_SkipsRpfEntries() + public void CollectFiles_SkipsRpfEntries_WhenRecursive() { RpfBinaryFileEntry rpfEntry = MakeEntry("nested.rpf"); RpfBinaryFileEntry fileEntry = MakeEntry("test.ydr"); RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = [rpfEntry, fileEntry] }; List<(RpfFile rpf, RpfFileEntry entry)> files = - RpfService.CollectFiles(rpf, null, recursive: false); + RpfService.CollectFiles(rpf, null, recursive: true); _ = Assert.Single(files); Assert.Equal("test.ydr", files[0].entry.Name); } + [Fact] + public void CollectFiles_IncludesRpfEntries_WhenNotRecursive() + { + RpfBinaryFileEntry rpfEntry = MakeEntry("nested.rpf"); + RpfBinaryFileEntry fileEntry = MakeEntry("test.ydr"); + RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = [rpfEntry, fileEntry] }; + List<(RpfFile rpf, RpfFileEntry entry)> files = + RpfService.CollectFiles(rpf, null, recursive: false); + Assert.Equal(2, files.Count); + Assert.Contains(files, f => f.entry.Name == "nested.rpf"); + Assert.Contains(files, f => f.entry.Name == "test.ydr"); + } + [Fact] public void CollectFiles_SkipsDirectoryEntries() { From b536725583bec94ba04cee34dc161621e1aa2c03 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:07:12 +0200 Subject: [PATCH 23/45] fix(cli): keep scan errors and encoding failures in the error result hash parsed --encoding before entering the try block, so an unrecognised name escaped Execute as an unhandled ArgumentException instead of the error result and exit code 1 that every other failure produces. stat declared its scanErrors list inside the try block, so the catch could not reach it: an archive that logged scan errors and then threw reported the exception alone and dropped everything found on the way. --- CodeWalker.Cli/Handlers/HashHandler.cs | 3 +- CodeWalker.Cli/Handlers/StatHandler.cs | 4 +- .../Tests/Handlers/HashHandlerTests.cs | 48 ++++++++++++------- 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/CodeWalker.Cli/Handlers/HashHandler.cs b/CodeWalker.Cli/Handlers/HashHandler.cs index 717eef2ce..6beb652ba 100644 --- a/CodeWalker.Cli/Handlers/HashHandler.cs +++ b/CodeWalker.Cli/Handlers/HashHandler.cs @@ -70,10 +70,9 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul /// An integer exit code indicating success (0) or failure (1). public static int Execute(HashOptions options, CancellationToken cancellationToken = default) { - JenkHashInputEncoding encoding = ParseEncoding(options.Encoding); - try { + JenkHashInputEncoding encoding = ParseEncoding(options.Encoding); Json.HashEntry[] hashes = CollectHashes(options.Inputs, encoding, cancellationToken); if (!options.Json) PrintHashes(hashes, cancellationToken); diff --git a/CodeWalker.Cli/Handlers/StatHandler.cs b/CodeWalker.Cli/Handlers/StatHandler.cs index fb8d27f77..31fd6adf2 100644 --- a/CodeWalker.Cli/Handlers/StatHandler.cs +++ b/CodeWalker.Cli/Handlers/StatHandler.cs @@ -44,9 +44,9 @@ public static int Execute(RpfOptions options, CancellationToken cancellationToke if (initError != null) return RpfService.ReportError(initError, options.Json, ErrorResult([], options)); + List scanErrors = []; try { - List scanErrors = []; RpfFile rpf = RpfService.OpenRpf( options.RpfPath, options.Verbose, @@ -78,7 +78,7 @@ public static int Execute(RpfOptions options, CancellationToken cancellationToke return RpfService.ReportError( ex.Message, options.Json, - ErrorResult([], options), + ErrorResult([.. scanErrors], options), options.Verbose ? ex.StackTrace : null ); } diff --git a/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs index f04b9d325..1c39d7daf 100644 --- a/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs @@ -370,37 +370,51 @@ public void Json_WritesValidJson() } [Fact] - public void InvalidEncoding_PropagatesArgumentException() + public void InvalidEncoding_ReturnsOne() { - TextWriter orig = Console.Out; + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; try { Console.SetOut(new StringWriter()); - _ = Assert.Throws( - () => HashHandler.Execute( - MakeOptions(["test"], encoding: "bad"), - TestContext.Current.CancellationToken - ) + StringWriter stderr = new(); + Console.SetError(stderr); + + int exitCode = HashHandler.Execute( + MakeOptions(["test"], encoding: "bad"), + TestContext.Current.CancellationToken ); + + Assert.Equal(1, exitCode); + Assert.Contains("Unknown encoding", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); } - finally { Console.SetOut(orig); } } [Fact] - public void Json_InvalidEncoding_PropagatesArgumentException() + public void Json_InvalidEncoding_ReturnsJsonError() { - TextWriter orig = Console.Out; + TextWriter origOut = Console.Out; try { - Console.SetOut(new StringWriter()); - _ = Assert.Throws( - () => HashHandler.Execute( - MakeOptions(["test"], encoding: "bad", json: true), - TestContext.Current.CancellationToken - ) + StringWriter stdout = new(); + Console.SetOut(stdout); + + int exitCode = HashHandler.Execute( + MakeOptions(["test"], encoding: "bad", json: true), + TestContext.Current.CancellationToken ); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("Unknown encoding", output); } - finally { Console.SetOut(orig); } + finally { Console.SetOut(origOut); } } [Fact] From abbb74aed58eb290f7af12bb9ae281a72dffa40e Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:07:12 +0200 Subject: [PATCH 24/45] fix(cli): count uncompressed entries in stat's compressed total An entry stores 0 in FileSize when it is not compressed, and its real size comes from FileUncompressedSize or from the system and graphics page sizes. stat's total already went through GetFileSize, which handles that, but compressedSize read FileSize directly, so every uncompressed entry contributed nothing to it and the compression ratio came out too low. The entry type test is a switch now, and an entry that is neither a resource nor a binary throws instead of being dropped from both counts. The extension table gains its outer border, so the columns close against the dividers that were already between them. Trailing commas in the initializers of these two files are gone; the rest of the project does not use them. --- CodeWalker.Cli/Handlers/HashHandler.cs | 20 ++--- CodeWalker.Cli/Handlers/StatHandler.cs | 109 +++++++++++++++---------- 2 files changed, 78 insertions(+), 51 deletions(-) diff --git a/CodeWalker.Cli/Handlers/HashHandler.cs b/CodeWalker.Cli/Handlers/HashHandler.cs index 6beb652ba..5473203db 100644 --- a/CodeWalker.Cli/Handlers/HashHandler.cs +++ b/CodeWalker.Cli/Handlers/HashHandler.cs @@ -25,25 +25,25 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul { Description = "Text string(s) to hash", Required = true, - AllowMultipleArgumentsPerToken = true, + AllowMultipleArgumentsPerToken = true }; Option encodingOption = new("--encoding", "-e") { Description = "Encoding: utf-8 (default), ascii", - DefaultValueFactory = _ => HashOptions.DefaultEncoding, + DefaultValueFactory = _ => HashOptions.DefaultEncoding }; Option jsonOption = new("--json") { - Description = "Output results in JSON format", + Description = "Output results in JSON format" }; Command command = new("hash", "Generate Jenkins hashes for GTA V game identifiers") { inputOption, encodingOption, - jsonOption, + jsonOption }; command.Aliases.Add("h"); @@ -53,7 +53,7 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul { Inputs = parseResult.GetRequiredValue(inputOption), Encoding = parseResult.GetRequiredValue(encodingOption), - Json = parseResult.GetValue(jsonOption), + Json = parseResult.GetValue(jsonOption) }; return Execute(options, cancellationToken); }); @@ -105,7 +105,7 @@ internal static Json.HashResult ErrorResult(string[] errorMessages) => { Success = false, Hashes = [], - ErrorMessages = errorMessages, + ErrorMessages = errorMessages }; /// @@ -119,7 +119,7 @@ internal static JenkHashInputEncoding ParseEncoding(string encoding) => { "UTF-8" => JenkHashInputEncoding.UTF8, "ASCII" => JenkHashInputEncoding.ASCII, - _ => throw new ArgumentException($"Unknown encoding: {encoding}. Use 'utf-8' or 'ascii'."), + _ => throw new ArgumentException($"Unknown encoding: {encoding}. Use 'utf-8' or 'ascii'.") }; /// @@ -141,13 +141,13 @@ CancellationToken cancellationToken cancellationToken.ThrowIfCancellationRequested(); JenkHash jenkHash = new(input, encoding); hashes.Add( - new Json.HashEntry() + new Json.HashEntry { Input = input, Hash = jenkHash.HashUint, HashSigned = jenkHash.HashInt, HashHex = jenkHash.HashHex, - Encoding = jenkHash.Encoding.ToString(), + Encoding = jenkHash.Encoding.ToString() } ); } @@ -164,7 +164,7 @@ internal static void PrintJsonHashes(Json.HashEntry[] hashes) { Success = true, Hashes = hashes, - ErrorMessages = [], + ErrorMessages = [] }; Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); } diff --git a/CodeWalker.Cli/Handlers/StatHandler.cs b/CodeWalker.Cli/Handlers/StatHandler.cs index 31fd6adf2..c910a5c77 100644 --- a/CodeWalker.Cli/Handlers/StatHandler.cs +++ b/CodeWalker.Cli/Handlers/StatHandler.cs @@ -42,7 +42,12 @@ public static int Execute(RpfOptions options, CancellationToken cancellationToke options.Json ); if (initError != null) - return RpfService.ReportError(initError, options.Json, ErrorResult([], options)); + { + return RpfService.ReportError( + initError, + options.Json, + ErrorResult([], options)); + } List scanErrors = []; try @@ -106,7 +111,7 @@ internal static Json.StatResult ErrorResult(string[] errorMessages, RpfOptions o UncompressedSizeFormatted = "0 B", CompressionRatio = 0, Extensions = [], - ErrorMessages = errorMessages, + ErrorMessages = errorMessages }; /// @@ -123,9 +128,9 @@ internal static Json.StatResult CollectStats( RpfOptions options, CancellationToken cancellationToken = default) { - long totalSize = 0; int resourceCount = 0; int binaryCount = 0; + long totalSize = 0; long compressedSize = 0; long uncompressedSize = 0; @@ -134,8 +139,10 @@ internal static Json.StatResult CollectStats( foreach ((RpfFile _, RpfFileEntry fileEntry) in entries) { cancellationToken.ThrowIfCancellationRequested(); + long size = fileEntry.GetFileSize(); totalSize += size; + string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); if (string.IsNullOrEmpty(ext)) ext = "(none)"; @@ -151,28 +158,35 @@ internal static Json.StatResult CollectStats( } else { - extStats[ext] = (1, size, size, size); + extStats[ext] = ( + 1, + size, + size, + size + ); } - if (fileEntry is RpfResourceFileEntry rfe) + switch (fileEntry) { - resourceCount++; - compressedSize += rfe.FileSize; - uncompressedSize += rfe.SystemSize + rfe.GraphicsSize; - } - else if (fileEntry is RpfBinaryFileEntry bfe) - { - binaryCount++; - compressedSize += bfe.FileSize; - uncompressedSize += bfe.FileUncompressedSize; + case RpfResourceFileEntry rfe: + resourceCount++; + compressedSize += size; + uncompressedSize += rfe.SystemSize + rfe.GraphicsSize; + break; + case RpfBinaryFileEntry bfe: + binaryCount++; + compressedSize += size; + uncompressedSize += bfe.FileUncompressedSize; + break; + default: + throw new InvalidOperationException($"Unknown file entry type: {fileEntry.GetType().FullName}"); } } double compressionRatio = uncompressedSize > 0 ? (double)compressedSize / uncompressedSize : 0; - List extensionStats = - [ + List extensionStats = [ .. extStats .OrderByDescending(kv => kv.Value.total) .Select(kv => new Json.ExtensionStat @@ -186,11 +200,11 @@ .. extStats MinSize = kv.Value.min, MinSizeFormatted = options.SizeFormat.ToFormattedString(kv.Value.min), MaxSize = kv.Value.max, - MaxSizeFormatted = options.SizeFormat.ToFormattedString(kv.Value.max), - }), + MaxSizeFormatted = options.SizeFormat.ToFormattedString(kv.Value.max) + }) ]; - return new Json.StatResult() + return new Json.StatResult { Success = scanErrors.Count == 0, RpfFile = options.RpfPath, @@ -205,7 +219,7 @@ .. extStats UncompressedSizeFormatted = options.SizeFormat.ToFormattedString(uncompressedSize), CompressionRatio = Math.Round(compressionRatio, 4), Extensions = extensionStats, - ErrorMessages = [.. scanErrors], + ErrorMessages = [.. scanErrors] }; } @@ -225,18 +239,19 @@ internal static void PrintStats(Json.StatResult result, RpfOptions options) { // Collect all rows for dynamic column sizing string[] headers = ["Extension", "Count", "Total", "Avg", "Min", "Max"]; - List rows = new(result.Extensions.Count); - foreach (Json.ExtensionStat ext in result.Extensions) - { - rows.Add([ - ext.Extension, - ext.Count.ToString(CultureInfo.InvariantCulture), - options.SizeFormat.ToFormattedString(ext.TotalSize), - options.SizeFormat.ToFormattedString(ext.AvgSize), - options.SizeFormat.ToFormattedString(ext.MinSize), - options.SizeFormat.ToFormattedString(ext.MaxSize), - ]); - } + string[][] rows = [ + .. result.Extensions + .Select(ext => + (string[])[ + ext.Extension, + ext.Count.ToString(CultureInfo.InvariantCulture), + options.SizeFormat.ToFormattedString(ext.TotalSize), + options.SizeFormat.ToFormattedString(ext.AvgSize), + options.SizeFormat.ToFormattedString(ext.MinSize), + options.SizeFormat.ToFormattedString(ext.MaxSize) + ] + ) + ]; // Calculate column widths from headers and data int[] widths = new int[headers.Length]; @@ -247,27 +262,39 @@ internal static void PrintStats(Json.StatResult result, RpfOptions options) for (int i = 0; i < row.Length; i++) widths[i] = Math.Max(widths[i], row[i].Length); - // Print header — first column left-aligned, rest right-aligned - Console.Write($" {headers[0].PadRight(widths[0])} "); + // Top border + Console.Write($"+{new string('-', widths[0] + 2)}"); + for (int i = 1; i < widths.Length; i++) + Console.Write($"+{new string('-', widths[i] + 2)}"); + Console.WriteLine("+"); + + // Header — first column left-aligned, rest right-aligned + Console.Write($"| {headers[0].PadRight(widths[0])} "); for (int i = 1; i < headers.Length; i++) Console.Write($"| {headers[i].PadLeft(widths[i])} "); - Console.WriteLine(); + Console.WriteLine("|"); - // Separator with column dividers - Console.Write(new string('-', widths[0] + 2)); + // Separator + Console.Write($"+{new string('-', widths[0] + 2)}"); for (int i = 1; i < widths.Length; i++) Console.Write($"+{new string('-', widths[i] + 2)}"); - Console.WriteLine(); + Console.WriteLine("+"); - // Print data rows + // Data rows foreach (string[] row in rows) { - Console.Write($" {row[0].PadRight(widths[0])} "); + Console.Write($"| {row[0].PadRight(widths[0])} "); for (int i = 1; i < row.Length; i++) Console.Write($"| {row[i].PadLeft(widths[i])} "); - Console.WriteLine(); + Console.WriteLine("|"); } + // Bottom border + Console.Write($"+{new string('-', widths[0] + 2)}"); + for (int i = 1; i < widths.Length; i++) + Console.Write($"+{new string('-', widths[i] + 2)}"); + Console.WriteLine("+"); + Console.Error.WriteLine(); Console.Error.WriteLine( $"Total: {result.TotalFiles} files, {options.SizeFormat.ToFormattedString(result.TotalSize)}" From 7cec7106aec821a41a48f3a05803ccbdfd86dd4a Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:07:55 +0200 Subject: [PATCH 25/45] test(cli): cover the stat handler's collection and printing CollectStats, PrintStats and PrintJsonStats had no tests of their own. These cover the extension grouping, the compression ratio, the size formatting in both unit systems, and the error paths. --- .../Tests/Handlers/HashHandlerTests.cs | 5 +- .../Tests/Handlers/StatHandlerTests.cs | 370 +++++++++++++++--- 2 files changed, 322 insertions(+), 53 deletions(-) diff --git a/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs index 1c39d7daf..39805d244 100644 --- a/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs @@ -129,7 +129,9 @@ public void Cancelled_ThrowsOperationCanceledException() cts.Cancel(); Json.HashEntry[] hashes = HashHandler.CollectHashes( - ["test"], JenkHashInputEncoding.UTF8, CancellationToken.None + ["test"], + JenkHashInputEncoding.UTF8, + CancellationToken.None ); TextWriter orig = Console.Out; @@ -217,7 +219,6 @@ public void MultipleInputs_ReturnsAll() Assert.Equal("alpha", result.Hashes[0].Input); Assert.Equal("bravo", result.Hashes[1].Input); } - } // ── CollectHashes ──────────────────────────────────────────────────── diff --git a/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs index 57ccb5770..bc38789de 100644 --- a/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs @@ -27,7 +27,7 @@ private static RpfOptions MakeOptions(string rpfPath = "/test.rpf") => Json = false, Recursive = false, Threads = 1, - SizeFormat = SizeFormat.IEC, + SizeFormat = SizeFormat.IEC }; [Fact] @@ -97,11 +97,16 @@ private static RpfOptions MakeOptions(SizeFormat fmt = SizeFormat.IEC) => Json = false, Recursive = false, Threads = 1, - SizeFormat = fmt, + SizeFormat = fmt }; private static RpfBinaryFileEntry MakeBinary(string name, uint fileSize, uint uncompressedSize) => - new() { Name = name, FileSize = fileSize, FileUncompressedSize = uncompressedSize }; + new() + { + Name = name, + FileSize = fileSize, + FileUncompressedSize = uncompressedSize + }; private static RpfResourceFileEntry MakeResource(string name, uint fileSize, uint sysFlags, uint gfxFlags) => new() @@ -109,13 +114,18 @@ private static RpfResourceFileEntry MakeResource(string name, uint fileSize, uin Name = name, FileSize = fileSize, SystemFlags = new RpfResourcePageFlags(sysFlags), - GraphicsFlags = new RpfResourcePageFlags(gfxFlags), + GraphicsFlags = new RpfResourcePageFlags(gfxFlags) }; [Fact] public void EmptyEntries_ReturnsAllZeros() { - Json.StatResult result = StatHandler.CollectStats([], [], MakeOptions(), TestContext.Current.CancellationToken); + Json.StatResult result = StatHandler.CollectStats( + [], + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); Assert.True(result.Success); Assert.Equal(0, result.TotalFiles); @@ -131,10 +141,22 @@ public void EmptyEntries_ReturnsAllZeros() [Fact] public void SingleBinary_CountsCorrectly() { - RpfBinaryFileEntry entry = MakeBinary("data.dat", fileSize: 200, uncompressedSize: 400); - List<(RpfFile, RpfFileEntry)> entries = [(null!, entry)]; + RpfBinaryFileEntry entry = MakeBinary( + "data.dat", + fileSize: 200, + uncompressedSize: 400 + ); - Json.StatResult result = StatHandler.CollectStats(entries, [], MakeOptions(), TestContext.Current.CancellationToken); + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, entry) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); Assert.Equal(1, result.TotalFiles); Assert.Equal(200, result.TotalSize); // GetFileSize() returns FileSize when non-zero @@ -148,10 +170,23 @@ public void SingleBinary_CountsCorrectly() public void SingleResource_CountsCorrectly() { // 0x08000000 → SystemFlags.Size = 512, 0x04000000 → GraphicsFlags.Size = 1024 - RpfResourceFileEntry entry = MakeResource("model.ydr", fileSize: 300, sysFlags: 0x08000000, gfxFlags: 0x04000000); - List<(RpfFile, RpfFileEntry)> entries = [(null!, entry)]; + RpfResourceFileEntry entry = MakeResource( + "model.ydr", + fileSize: 300, + sysFlags: 0x08000000, + gfxFlags: 0x04000000 + ); - Json.StatResult result = StatHandler.CollectStats(entries, [], MakeOptions(), TestContext.Current.CancellationToken); + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, entry) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); Assert.Equal(1, result.TotalFiles); Assert.Equal(300, result.TotalSize); // GetFileSize() returns FileSize when non-zero @@ -164,11 +199,30 @@ public void SingleResource_CountsCorrectly() [Fact] public void MixedEntries_AggregatesCorrectly() { - RpfBinaryFileEntry bin = MakeBinary("data.dat", fileSize: 200, uncompressedSize: 400); - RpfResourceFileEntry res = MakeResource("model.ydr", fileSize: 300, sysFlags: 0x08000000, gfxFlags: 0x04000000); - List<(RpfFile, RpfFileEntry)> entries = [(null!, bin), (null!, res)]; + RpfBinaryFileEntry bin = MakeBinary( + "data.dat", + fileSize: 200, + uncompressedSize: 400 + ); - Json.StatResult result = StatHandler.CollectStats(entries, [], MakeOptions(), TestContext.Current.CancellationToken); + RpfResourceFileEntry res = MakeResource( + "model.ydr", + fileSize: 300, + sysFlags: 0x08000000, + gfxFlags: 0x04000000 + ); + + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, bin), + (null!, res) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); Assert.Equal(2, result.TotalFiles); Assert.Equal(200 + 300, result.TotalSize); @@ -181,10 +235,22 @@ public void MixedEntries_AggregatesCorrectly() [Fact] public void CompressionRatio_CalculatedCorrectly() { - RpfBinaryFileEntry entry = MakeBinary("data.dat", fileSize: 250, uncompressedSize: 1000); - List<(RpfFile, RpfFileEntry)> entries = [(null!, entry)]; + RpfBinaryFileEntry entry = MakeBinary( + "data.dat", + fileSize: 250, + uncompressedSize: 1000 + ); - Json.StatResult result = StatHandler.CollectStats(entries, [], MakeOptions(), TestContext.Current.CancellationToken); + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, entry) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); Assert.Equal(0.25, result.CompressionRatio); } @@ -193,10 +259,22 @@ public void CompressionRatio_CalculatedCorrectly() public void CompressionRatio_ZeroWhenNoUncompressed() { // Entry with FileSize=0 and FileUncompressedSize=0 → GetFileSize() returns 0 - RpfBinaryFileEntry entry = MakeBinary("empty.dat", fileSize: 0, uncompressedSize: 0); - List<(RpfFile, RpfFileEntry)> entries = [(null!, entry)]; + RpfBinaryFileEntry entry = MakeBinary( + "empty.dat", + fileSize: 0, + uncompressedSize: 0 + ); - Json.StatResult result = StatHandler.CollectStats(entries, [], MakeOptions(), TestContext.Current.CancellationToken); + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, entry) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); Assert.Equal(0, result.CompressionRatio); } @@ -204,12 +282,34 @@ public void CompressionRatio_ZeroWhenNoUncompressed() [Fact] public void ExtensionStats_GroupedAndSorted() { - RpfBinaryFileEntry small = MakeBinary("a.dat", fileSize: 100, uncompressedSize: 100); - RpfBinaryFileEntry large1 = MakeBinary("b.ydr", fileSize: 500, uncompressedSize: 500); - RpfBinaryFileEntry large2 = MakeBinary("c.ydr", fileSize: 600, uncompressedSize: 600); - List<(RpfFile, RpfFileEntry)> entries = [(null!, small), (null!, large1), (null!, large2)]; + RpfBinaryFileEntry small = MakeBinary( + "a.dat", + fileSize: 100, + uncompressedSize: 100 + ); + RpfBinaryFileEntry large1 = MakeBinary( + "b.ydr", + fileSize: 500, + uncompressedSize: 500 + ); + RpfBinaryFileEntry large2 = MakeBinary( + "c.ydr", + fileSize: 600, + uncompressedSize: 600 + ); - Json.StatResult result = StatHandler.CollectStats(entries, [], MakeOptions(), TestContext.Current.CancellationToken); + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, small), + (null!, large1), + (null!, large2) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); Assert.Equal(2, result.Extensions.Count); // .ydr total (1100) > .dat total (100), so .ydr comes first @@ -224,11 +324,28 @@ public void ExtensionStats_GroupedAndSorted() [Fact] public void ExtensionStats_MinMaxAvg() { - RpfBinaryFileEntry a = MakeBinary("a.dat", fileSize: 100, uncompressedSize: 100); - RpfBinaryFileEntry b = MakeBinary("b.dat", fileSize: 300, uncompressedSize: 300); - List<(RpfFile, RpfFileEntry)> entries = [(null!, a), (null!, b)]; + RpfBinaryFileEntry a = MakeBinary( + "a.dat", + fileSize: 100, + uncompressedSize: 100 + ); + RpfBinaryFileEntry b = MakeBinary( + "b.dat", + fileSize: 300, + uncompressedSize: 300 + ); - Json.StatResult result = StatHandler.CollectStats(entries, [], MakeOptions(), TestContext.Current.CancellationToken); + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, a), + (null!, b) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); Json.ExtensionStat ext = result.Extensions[0]; Assert.Equal(".dat", ext.Extension); @@ -240,10 +357,22 @@ public void ExtensionStats_MinMaxAvg() [Fact] public void NoExtension_CategorizedAsNone() { - RpfBinaryFileEntry entry = MakeBinary("README", fileSize: 50, uncompressedSize: 50); - List<(RpfFile, RpfFileEntry)> entries = [(null!, entry)]; + RpfBinaryFileEntry entry = MakeBinary( + "README", + fileSize: 50, + uncompressedSize: 50 + ); - Json.StatResult result = StatHandler.CollectStats(entries, [], MakeOptions(), TestContext.Current.CancellationToken); + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, entry) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); Assert.Equal("(none)", result.Extensions[0].Extension); } @@ -252,32 +381,142 @@ public void NoExtension_CategorizedAsNone() public void ScanErrors_SetSuccessFalse() { List errors = ["scan failed"]; - Json.StatResult result = StatHandler.CollectStats([], errors, MakeOptions(), TestContext.Current.CancellationToken); + + Json.StatResult result = StatHandler.CollectStats( + [], + errors, + MakeOptions(), + TestContext.Current.CancellationToken + ); Assert.False(result.Success); - Assert.Equal(["scan failed"], result.ErrorMessages); + Assert.Equal(errors, result.ErrorMessages); } [Fact] public void ScanErrors_Empty_SetSuccessTrue() { - Json.StatResult result = StatHandler.CollectStats([], [], MakeOptions(), TestContext.Current.CancellationToken); + Json.StatResult result = StatHandler.CollectStats( + [], + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); Assert.True(result.Success); Assert.Empty(result.ErrorMessages); } + [Fact] + public void CaseInsensitiveExtensionGrouping() + { + RpfBinaryFileEntry upper = MakeBinary( + "A.DAT", + fileSize: 100, + uncompressedSize: 100 + ); + RpfBinaryFileEntry lower = MakeBinary( + "b.dat", + fileSize: 200, + uncompressedSize: 200 + ); + + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, upper), + (null!, lower) + ]; + + Json.StatResult result = StatHandler.CollectStats(entries, [], MakeOptions(), TestContext.Current.CancellationToken); + + _ = Assert.Single(result.Extensions); + Assert.Equal(".dat", result.Extensions[0].Extension); + Assert.Equal(2, result.Extensions[0].Count); + Assert.Equal(300, result.Extensions[0].TotalSize); + } + + [Fact] + public void CompressionRatio_RoundedToFourDecimals() + { + // 1 / 3 = 0.33333... → should round to 0.3333 + RpfBinaryFileEntry entry = MakeBinary( + "data.dat", + fileSize: 1, + uncompressedSize: 3 + ); + + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, entry) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.Equal(0.3333, result.CompressionRatio); + } + + [Fact] + public void AvgSize_TruncatedByIntegerDivision() + { + // 3 files totalling 10 bytes → avg = 10 / 3 = 3 (integer truncation, not 3.33) + RpfBinaryFileEntry a = MakeBinary( + "a.dat", + fileSize: 1, + uncompressedSize: 1 + ); + RpfBinaryFileEntry b = MakeBinary( + "b.dat", + fileSize: 4, + uncompressedSize: 4 + ); + RpfBinaryFileEntry c = MakeBinary( + "c.dat", + fileSize: 5, + uncompressedSize: 5 + ); + + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, a), + (null!, b), + (null!, c) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.Equal(3, result.Extensions[0].AvgSize); // 10 / 3 = 3, not 4 + } + [Fact] public void Cancelled_ThrowsOperationCanceledException() { using CancellationTokenSource cts = new(); cts.Cancel(); - RpfBinaryFileEntry entry = MakeBinary("data.dat", fileSize: 100, uncompressedSize: 100); - List<(RpfFile, RpfFileEntry)> entries = [(null!, entry)]; + RpfBinaryFileEntry entry = MakeBinary( + "data.dat", + fileSize: 100, + uncompressedSize: 100 + ); + + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, entry) + ]; _ = Assert.Throws( - () => StatHandler.CollectStats(entries, [], MakeOptions(), cts.Token) + () => StatHandler.CollectStats( + entries, + [], + MakeOptions(), + cts.Token + ) ); } } @@ -323,10 +562,10 @@ private static Json.StatResult MakeResult( MinSize = 200, MinSizeFormatted = "200 B", MaxSize = 300, - MaxSizeFormatted = "300 B", - }, + MaxSizeFormatted = "300 B" + } ], - ErrorMessages = [], + ErrorMessages = [] }; [Fact] @@ -460,7 +699,7 @@ private static RpfOptions MakeOptions(SizeFormat fmt = SizeFormat.IEC) => Json = false, Recursive = false, Threads = 1, - SizeFormat = fmt, + SizeFormat = fmt }; private static (string stdout, string stderr) Capture(Json.StatResult result, RpfOptions? options = null) @@ -512,10 +751,10 @@ private static Json.StatResult MakeResult(IReadOnlyList? ext MinSize = 200, MinSizeFormatted = "200 B", MaxSize = 800, - MaxSizeFormatted = "800 B", - }, + MaxSizeFormatted = "800 B" + } ], - ErrorMessages = [], + ErrorMessages = [] }; [Fact] @@ -576,6 +815,23 @@ public void PrintStats_EmptyExtensions_PrintsHeaderOnly() Assert.Contains("Extension", stdout); Assert.DoesNotContain(".ydr", stdout); } + + [Fact] + public void PrintStats_SIFormat_UsesDecimalUnits() + { + Json.StatResult result = MakeResult() with + { + TotalSize = 2000, + TotalSizeFormatted = SizeFormat.SI.ToFormattedString(2000) + }; + RpfOptions options = MakeOptions(SizeFormat.SI); + + (string stdout, string stderr) = Capture(result, options); + + // SI uses KB (1000-based) not KiB (1024-based); format is "0.##" so "2 KB" not "2.0 KB" + Assert.Contains("2 KB", stderr); + Assert.DoesNotContain("KiB", stdout); + } } // ── Execute (integration) ──────────────────────────────────────────── @@ -594,7 +850,7 @@ private static RpfOptions MakeOptions(string rpfPath, bool json) => Json = json, Recursive = false, Threads = 1, - SizeFormat = SizeFormat.IEC, + SizeFormat = SizeFormat.IEC }; // ── Validation failures ──────────────────────────────────────────── @@ -610,7 +866,10 @@ public void Execute_MissingRpf_ReturnsOne() Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = StatHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), TestContext.Current.CancellationToken); + int exitCode = StatHandler.Execute( + MakeOptions("/nonexistent/test.rpf", json: false), + TestContext.Current.CancellationToken + ); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); @@ -633,7 +892,10 @@ public void Execute_MissingRpf_Json_ReturnsErrorJson() Console.SetOut(stdout); Console.SetError(new StringWriter()); - int exitCode = StatHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); + int exitCode = StatHandler.Execute( + MakeOptions("/nonexistent/test.rpf", json: true), + TestContext.Current.CancellationToken + ); Assert.Equal(1, exitCode); string output = stdout.ToString(); @@ -656,7 +918,10 @@ public void Execute_Json_ErrorContainsAllExpectedFields() StringWriter stdout = new(); Console.SetOut(stdout); - _ = StatHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); + _ = StatHandler.Execute( + MakeOptions("/nonexistent/test.rpf", json: true), + TestContext.Current.CancellationToken + ); string output = stdout.ToString(); Assert.Contains("\"rpfFile\":", output); @@ -692,7 +957,10 @@ public void Execute_MissingExe_WithExistingRpf_ReturnsOne() StringWriter stderr = new(); Console.SetError(stderr); - int exitCode = StatHandler.Execute(MakeOptions(rpf, json: false), TestContext.Current.CancellationToken); + int exitCode = StatHandler.Execute( + MakeOptions(rpf, json: false), + TestContext.Current.CancellationToken + ); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); From 67360c1a660318ae84ef532dfb2e1a2ed7448275 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:07:55 +0200 Subject: [PATCH 26/45] refactor(cli): split the list, tree and validate handlers for testing Same split as hash and stat: a Collect step that returns the JSON result record, and a Print step per output format. Every handler declared its scanErrors list inside the try block, so the catch could not reach it and an archive that logged scan errors before throwing reported only the exception. The list is declared before the try in extract, inspect, list, stat and validate, and the catch passes it on. list no longer takes --threads. It never spawned any. tree's JSON nodes carry the resource version, which the human-readable output was already printing. --- CodeWalker.Cli/Handlers/ExtractHandler.cs | 4 +- CodeWalker.Cli/Handlers/HashHandler.cs | 12 +- CodeWalker.Cli/Handlers/InspectHandler.cs | 6 +- CodeWalker.Cli/Handlers/ListHandler.cs | 181 +-- CodeWalker.Cli/Handlers/SearchHandler.cs | 6 +- CodeWalker.Cli/Handlers/StatHandler.cs | 22 +- CodeWalker.Cli/Handlers/TreeHandler.cs | 353 ++--- CodeWalker.Cli/Handlers/ValidateHandler.cs | 4 +- CodeWalker.Cli/Json/TreeResult.cs | 4 + CodeWalker.Cli/RpfOptions.cs | 4 +- .../Tests/Handlers/ListHandlerTests.cs | 576 +++++++- .../Tests/Handlers/StatHandlerTests.cs | 2 +- .../Tests/Handlers/TreeHandlerTests.cs | 1213 ++++++++++++++++- CodeWalker.Cli/Tests/RpfOptionsTests.cs | 20 + 14 files changed, 2122 insertions(+), 285 deletions(-) diff --git a/CodeWalker.Cli/Handlers/ExtractHandler.cs b/CodeWalker.Cli/Handlers/ExtractHandler.cs index 0c1259dbf..b3c5e39bf 100644 --- a/CodeWalker.Cli/Handlers/ExtractHandler.cs +++ b/CodeWalker.Cli/Handlers/ExtractHandler.cs @@ -100,9 +100,9 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => return RpfService.ReportError(initError, options.Rpf.Json, ErrorResult([])); } + List scanErrors = []; try { - List scanErrors = []; RpfFile rpf = RpfService.OpenRpf( options.Rpf.RpfPath, options.Rpf.Verbose, @@ -326,7 +326,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => return RpfService.ReportError( ex.Message, options.Rpf.Json, - ErrorResult([]), + ErrorResult([.. scanErrors]), options.Rpf.Verbose ? ex.StackTrace : null ); } diff --git a/CodeWalker.Cli/Handlers/HashHandler.cs b/CodeWalker.Cli/Handlers/HashHandler.cs index 5473203db..aa77800cf 100644 --- a/CodeWalker.Cli/Handlers/HashHandler.cs +++ b/CodeWalker.Cli/Handlers/HashHandler.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.CommandLine; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Threading; @@ -8,6 +9,7 @@ namespace CodeWalker.Cli.Handlers; +[ExcludeFromCodeCoverage] internal sealed record HashOptions { public required string[] Inputs { get; init; } @@ -74,10 +76,10 @@ public static int Execute(HashOptions options, CancellationToken cancellationTok { JenkHashInputEncoding encoding = ParseEncoding(options.Encoding); Json.HashEntry[] hashes = CollectHashes(options.Inputs, encoding, cancellationToken); - if (!options.Json) - PrintHashes(hashes, cancellationToken); - else + if (options.Json) PrintJsonHashes(hashes); + else + PrintHashes(hashes, cancellationToken); return 0; } @@ -91,7 +93,8 @@ public static int Execute(HashOptions options, CancellationToken cancellationTok return RpfService.ReportError( ex.Message, options.Json, - ErrorResult([])); + ErrorResult([]) + ); } } @@ -151,6 +154,7 @@ CancellationToken cancellationToken } ); } + return [.. hashes]; } diff --git a/CodeWalker.Cli/Handlers/InspectHandler.cs b/CodeWalker.Cli/Handlers/InspectHandler.cs index ef0bd19c6..ef7be2eb2 100644 --- a/CodeWalker.Cli/Handlers/InspectHandler.cs +++ b/CodeWalker.Cli/Handlers/InspectHandler.cs @@ -71,9 +71,9 @@ Json.InspectResult ErrorResult(string[] errorMessages) => return RpfService.ReportError(initError, options.Json, ErrorResult([])); } + List scanErrors = []; try { - List scanErrors = []; RpfFile rpf = RpfService.OpenRpf( options.RpfPath, options.Verbose, @@ -95,7 +95,7 @@ Json.InspectResult ErrorResult(string[] errorMessages) => return RpfService.ReportError( $"File not found in archive: {filePath}", options.Json, - ErrorResult([]) + ErrorResult([.. scanErrors]) ); } @@ -162,7 +162,7 @@ Json.InspectResult ErrorResult(string[] errorMessages) => return RpfService.ReportError( ex.Message, options.Json, - ErrorResult([]), + ErrorResult([.. scanErrors]), options.Verbose ? ex.StackTrace : null ); } diff --git a/CodeWalker.Cli/Handlers/ListHandler.cs b/CodeWalker.Cli/Handlers/ListHandler.cs index cdc588dd6..c0a6ef780 100644 --- a/CodeWalker.Cli/Handlers/ListHandler.cs +++ b/CodeWalker.Cli/Handlers/ListHandler.cs @@ -17,7 +17,7 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul RpfCommandOptions rpfOpts = new(); Command command = new("list", "List contents of an RPF archive"); - rpfOpts.AddTo(command); + rpfOpts.AddTo(command, includeThreads: false); command.Aliases.Add("l"); command.SetAction(parseResult => Execute(rpfOpts.Parse(parseResult), cancellationToken)); @@ -27,19 +27,6 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul public static int Execute(RpfOptions options, CancellationToken cancellationToken = default) { - Json.ListResult ErrorResult(string[] errorMessages) => - new() - { - Success = false, - RpfFile = options.RpfPath, - TotalFiles = 0, - TotalSize = 0, - TotalSizeFormatted = "0 B", - NestedRpfCount = 0, - Files = [], - ErrorMessages = errorMessages, - }; - string? initError = RpfService.ValidateAndLoadKeys( options.RpfPath, options.ExePath, @@ -48,12 +35,16 @@ Json.ListResult ErrorResult(string[] errorMessages) => ); if (initError != null) { - return RpfService.ReportError(initError, options.Json, ErrorResult([])); + return RpfService.ReportError( + initError, + options.Json, + ErrorResult([], options) + ); } + List scanErrors = []; try { - List scanErrors = []; RpfFile rpf = RpfService.OpenRpf( options.RpfPath, options.Verbose, @@ -61,92 +52,122 @@ Json.ListResult ErrorResult(string[] errorMessages) => scanErrors ); - long nestedRpfCount = rpf.GrandTotalRpfCount; - if (!options.Json) - { Console.Error.WriteLine(); - } - // Collect all matching entries List<(RpfFile rpf, RpfFileEntry entry)> entries = RpfService.CollectFiles( rpf, options.Filters, options.Recursive ); - long totalSize = 0; - List files = []; - - foreach ((RpfFile _, RpfFileEntry fileEntry) in entries) - { - cancellationToken.ThrowIfCancellationRequested(); - long size = fileEntry.GetFileSize(); - totalSize += size; - string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); - - if (options.Json) - { - files.Add( - new Json.FileEntry - { - Path = fileEntry.Path, - Name = fileEntry.Name, - Size = size, - SizeFormatted = options.SizeFormat.ToFormattedString(size), - Type = RpfService.GetFileType(fileEntry), - Extension = ext, - } - ); - } - else if (options.Verbose) - { - string sizeStr = options.SizeFormat.ToFormattedString(size).PadLeft(12); - Console.WriteLine($"{sizeStr} {fileEntry.Path}"); - } - else - { - Console.WriteLine(fileEntry.Path); - } - } - - Json.ListResult result = new() - { - Success = scanErrors.Count == 0, - RpfFile = options.RpfPath, - TotalFiles = entries.Count, - TotalSize = totalSize, - TotalSizeFormatted = options.SizeFormat.ToFormattedString(totalSize), - NestedRpfCount = nestedRpfCount, - Files = [.. files], - ErrorMessages = [.. scanErrors], - }; + Json.ListResult result = CollectList(entries, rpf, scanErrors, options, cancellationToken); if (options.Json) - { - Console.WriteLine( - JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) - ); - } + PrintJsonList(result); else - { - Console.Error.WriteLine(); - Console.Error.WriteLine( - $"Total: {entries.Count} files, {options.SizeFormat.ToFormattedString(totalSize)}" - ); - } + PrintList(result, options, cancellationToken); return scanErrors.Count > 0 ? 1 : 0; } - catch (OperationCanceledException) { throw; } + catch (OperationCanceledException) + { + // Gracefully handle cancellation without printing an error message + throw; + } catch (Exception ex) { return RpfService.ReportError( ex.Message, options.Json, - ErrorResult([]), + ErrorResult([.. scanErrors], options), options.Verbose ? ex.StackTrace : null ); } } + + internal static Json.ListResult ErrorResult(string[] errorMessages, RpfOptions options) => + new() + { + Success = false, + RpfFile = options.RpfPath, + TotalFiles = 0, + TotalSize = 0, + TotalSizeFormatted = "0 B", + NestedRpfCount = 0, + Files = [], + ErrorMessages = errorMessages, + }; + + internal static Json.ListResult CollectList( + List<(RpfFile rpf, RpfFileEntry entry)> entries, + RpfFile rpf, + List scanErrors, + RpfOptions options, + CancellationToken cancellationToken = default) + { + long totalSize = 0; + List files = []; + + foreach ((RpfFile _, RpfFileEntry fileEntry) in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + long size = fileEntry.GetFileSize(); + totalSize += size; + string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); + + files.Add( + new Json.FileEntry + { + Path = fileEntry.Path, + Name = fileEntry.Name, + Size = size, + SizeFormatted = options.SizeFormat.ToFormattedString(size), + Type = RpfService.GetFileType(fileEntry), + Extension = ext, + } + ); + } + + return new Json.ListResult + { + Success = scanErrors.Count == 0, + RpfFile = options.RpfPath, + TotalFiles = entries.Count, + TotalSize = totalSize, + TotalSizeFormatted = options.SizeFormat.ToFormattedString(totalSize), + NestedRpfCount = rpf.GrandTotalRpfCount, + Files = [.. files], + ErrorMessages = [.. scanErrors], + }; + } + + internal static void PrintJsonList(Json.ListResult result) => + Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); + + internal static void PrintList( + Json.ListResult result, + RpfOptions options, + CancellationToken cancellationToken = default) + { + foreach (Json.FileEntry file in result.Files) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (options.Verbose) + { + string sizeStr = file.SizeFormatted.PadLeft(12); + Console.WriteLine($"{sizeStr} {file.Path}"); + } + else + { + Console.WriteLine(file.Path); + } + } + + Console.Error.WriteLine(); + Console.Error.WriteLine( + $"Total: {result.TotalFiles} files, {result.TotalSizeFormatted}" + ); + } } diff --git a/CodeWalker.Cli/Handlers/SearchHandler.cs b/CodeWalker.Cli/Handlers/SearchHandler.cs index 77c658fa8..52e2e328e 100644 --- a/CodeWalker.Cli/Handlers/SearchHandler.cs +++ b/CodeWalker.Cli/Handlers/SearchHandler.cs @@ -61,9 +61,9 @@ Json.SearchResult ErrorResult(string[] errorMessages) => return RpfService.ReportError(initError, options.Json, ErrorResult([])); } + List scanErrors = []; try { - List scanErrors = []; RpfFile rpf = RpfService.OpenRpf( options.RpfPath, options.Verbose, @@ -100,7 +100,7 @@ out uint hash return RpfService.ReportError( $"Invalid hex hash: {pattern}", options.Json, - ErrorResult([]) + ErrorResult([.. scanErrors]) ); } matcher = entry => entry.NameHash == hash || entry.ShortNameHash == hash; @@ -219,7 +219,7 @@ out uint hash return RpfService.ReportError( ex.Message, options.Json, - ErrorResult([]), + ErrorResult([.. scanErrors]), options.Verbose ? ex.StackTrace : null ); } diff --git a/CodeWalker.Cli/Handlers/StatHandler.cs b/CodeWalker.Cli/Handlers/StatHandler.cs index c910a5c77..021a9af99 100644 --- a/CodeWalker.Cli/Handlers/StatHandler.cs +++ b/CodeWalker.Cli/Handlers/StatHandler.cs @@ -19,7 +19,7 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul RpfCommandOptions rpfOpts = new(); Command command = new("stat", "Show aggregate statistics for RPF archive contents"); - rpfOpts.AddTo(command); + rpfOpts.AddTo(command, includeThreads: false); command.Aliases.Add("S"); command.SetAction(parseResult => Execute(rpfOpts.Parse(parseResult), cancellationToken)); @@ -46,7 +46,8 @@ public static int Execute(RpfOptions options, CancellationToken cancellationToke return RpfService.ReportError( initError, options.Json, - ErrorResult([], options)); + ErrorResult([], options) + ); } List scanErrors = []; @@ -73,11 +74,15 @@ public static int Execute(RpfOptions options, CancellationToken cancellationToke if (options.Json) PrintJsonStats(result); else - PrintStats(result, options); + PrintStats(result, options, cancellationToken); return scanErrors.Count > 0 ? 1 : 0; } - catch (OperationCanceledException) { throw; } + catch (OperationCanceledException) + { + // Gracefully handle cancellation without printing an error message + throw; + } catch (Exception ex) { return RpfService.ReportError( @@ -196,7 +201,10 @@ .. extStats TotalSize = kv.Value.total, TotalSizeFormatted = options.SizeFormat.ToFormattedString(kv.Value.total), AvgSize = kv.Value.count > 0 ? kv.Value.total / kv.Value.count : 0, - AvgSizeFormatted = options.SizeFormat.ToFormattedString(kv.Value.count > 0 ? kv.Value.total / kv.Value.count : 0), + AvgSizeFormatted = + options.SizeFormat.ToFormattedString(kv.Value.count > 0 + ? kv.Value.total / kv.Value.count + : 0), MinSize = kv.Value.min, MinSizeFormatted = options.SizeFormat.ToFormattedString(kv.Value.min), MaxSize = kv.Value.max, @@ -235,8 +243,10 @@ internal static void PrintJsonStats(Json.StatResult result) => /// /// The collected statistics to print. /// The options used to format size values in the output. - internal static void PrintStats(Json.StatResult result, RpfOptions options) + /// A cancellation token to observe while printing. + internal static void PrintStats(Json.StatResult result, RpfOptions options, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); // Collect all rows for dynamic column sizing string[] headers = ["Extension", "Count", "Total", "Avg", "Min", "Max"]; string[][] rows = [ diff --git a/CodeWalker.Cli/Handlers/TreeHandler.cs b/CodeWalker.Cli/Handlers/TreeHandler.cs index f6878e7f2..6edf0db82 100644 --- a/CodeWalker.Cli/Handlers/TreeHandler.cs +++ b/CodeWalker.Cli/Handlers/TreeHandler.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.CommandLine; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text.Json; @@ -11,12 +12,15 @@ namespace CodeWalker.Cli.Handlers; +[ExcludeFromCodeCoverage] internal sealed record TreeOptions { public required RpfOptions Rpf { get; init; } public required int Depth { get; init; } } +internal readonly record struct ChildItem(string Name, bool IsDir, RpfEntry Entry, RpfFile? ChildRpf, RpfFileEntry? ArchiveEntry = null); + internal static class TreeHandler { public static Command CreateCommand(CancellationToken cancellationToken = default) @@ -25,7 +29,7 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul Option depthOption = new("--depth", "-d") { Description = "Maximum depth to display (default: unlimited)", - DefaultValueFactory = _ => -1, + DefaultValueFactory = _ => -1 }; depthOption.Validators.Add(result => @@ -36,9 +40,9 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul Command command = new("tree", "Display a visual tree of the RPF directory structure") { - depthOption, + depthOption }; - rpfOpts.AddTo(command); + rpfOpts.AddTo(command, includeThreads: false); command.Aliases.Add("t"); command.SetAction(parseResult => @@ -46,7 +50,7 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul TreeOptions options = new() { Rpf = rpfOpts.Parse(parseResult), - Depth = parseResult.GetValue(depthOption), + Depth = parseResult.GetValue(depthOption) }; return Execute(options, cancellationToken); }); @@ -56,17 +60,6 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul public static int Execute(TreeOptions options, CancellationToken cancellationToken = default) { - Json.TreeResult ErrorResult(string[] errorMessages) => - new() - { - Success = false, - RpfFile = options.Rpf.RpfPath, - TotalFiles = 0, - TotalDirs = 0, - Root = null, - ErrorMessages = errorMessages, - }; - string? initError = RpfService.ValidateAndLoadKeys( options.Rpf.RpfPath, options.Rpf.ExePath, @@ -75,12 +68,16 @@ Json.TreeResult ErrorResult(string[] errorMessages) => ); if (initError != null) { - return RpfService.ReportError(initError, options.Rpf.Json, ErrorResult([])); + return RpfService.ReportError( + initError, + options.Rpf.Json, + ErrorResult([], options) + ); } + List scanErrors = []; try { - List scanErrors = []; RpfFile rpf = RpfService.OpenRpf( options.Rpf.RpfPath, options.Rpf.Verbose, @@ -91,130 +88,97 @@ Json.TreeResult ErrorResult(string[] errorMessages) => int totalFiles = 0; int totalDirs = 0; - if (options.Rpf.Json) - { - Json.TreeNode rootNode = BuildTreeNode( - rpf.Root, - rpf, - options, - 0, - ref totalFiles, - ref totalDirs, - cancellationToken - ); - - Json.TreeResult result = new() - { - Success = scanErrors.Count == 0, - RpfFile = options.Rpf.RpfPath, - TotalFiles = totalFiles, - TotalDirs = totalDirs, - Root = rootNode, - ErrorMessages = [.. scanErrors], - }; + Json.TreeNode rootNode = BuildTreeNode( + rpf.Root, + rpf, + options, + 0, + ref totalFiles, + ref totalDirs, + cancellationToken + ) with + { Name = Path.GetFileName(options.Rpf.RpfPath) + "/" }; - Console.WriteLine( - JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) - ); - } + if (options.Rpf.Json) + PrintJsonTree(rootNode, totalFiles, totalDirs, scanErrors, options); else - { - Console.WriteLine(Path.GetFileName(options.Rpf.RpfPath)); - PrintTree(rpf.Root, rpf, options, "", 0, ref totalFiles, ref totalDirs, cancellationToken); - - Console.Error.WriteLine(); - Console.Error.WriteLine($"{totalDirs} directories, {totalFiles} files"); - } + PrintTree(rootNode, totalFiles, totalDirs, options, cancellationToken); return scanErrors.Count > 0 ? 1 : 0; } - catch (OperationCanceledException) { throw; } + catch (OperationCanceledException) + { + // Gracefully handle cancellation without printing an error message + throw; + } catch (Exception ex) { return RpfService.ReportError( ex.Message, options.Rpf.Json, - ErrorResult([]), + ErrorResult([.. scanErrors], options), options.Rpf.Verbose ? ex.StackTrace : null ); } } - private static void PrintTree( - RpfDirectoryEntry dir, - RpfFile rpf, - TreeOptions options, - string prefix, - int depth, - ref int totalFiles, - ref int totalDirs, - CancellationToken cancellationToken - ) - { - cancellationToken.ThrowIfCancellationRequested(); - if (options.Depth >= 0 && depth > options.Depth) - return; + internal static Json.TreeResult ErrorResult(string[] errorMessages, TreeOptions options) => + new() + { + Success = false, + RpfFile = options.Rpf.RpfPath, + TotalFiles = 0, + TotalDirs = 0, + Root = null, + ErrorMessages = errorMessages + }; - List<(string name, bool isDir, RpfEntry entry, RpfFile? childRpf)> items = CollectChildren( - dir, - rpf, - options - ); + internal static List CollectChildren(RpfDirectoryEntry dir, RpfFile rpf, TreeOptions options) + { + List items = []; + HashSet expandedRpfs = new(StringComparer.Ordinal); - for (int i = 0; i < items.Count; i++) + // Add subdirectories + if (dir.Directories != null) { - bool isLast = i == items.Count - 1; - string connector = isLast ? "\u2514\u2500\u2500 " : "\u251c\u2500\u2500 "; - string childPrefix = prefix + (isLast ? " " : "\u2502 "); - - (string name, bool isDirectory, RpfEntry entry, RpfFile? childRpf) = items[i]; + foreach (RpfDirectoryEntry subDir in dir.Directories) + items.Add(new ChildItem(subDir.Name, true, subDir, null)); + } - if (isDirectory) + // Add nested RPFs as expandable directories if recursive + if (options.Rpf.Recursive && dir.Files != null && rpf.Children != null) + { + foreach (RpfFileEntry fileEntry in dir.Files) { - totalDirs++; - string display = name + "/"; - Console.WriteLine($"{prefix}{connector}{display}"); + if (!fileEntry.NameLower.EndsWith(".rpf", StringComparison.Ordinal)) + continue; - if (entry is RpfDirectoryEntry subDir) - { - PrintTree( - subDir, - childRpf ?? rpf, - options, - childPrefix, - depth + 1, - ref totalFiles, - ref totalDirs, - cancellationToken - ); - } - } - else - { - totalFiles++; - if (options.Rpf.Verbose && entry is RpfFileEntry fileEntry) - { - long size = fileEntry.GetFileSize(); - string sizeStr = options.Rpf.SizeFormat.ToFormattedString(size); - string fileType = RpfService.GetFileType(fileEntry); - string versionStr = ""; - if (fileEntry is RpfResourceFileEntry rfe) - { - versionStr = $" v{rfe.Version}"; - } - Console.WriteLine( - $"{prefix}{connector}{name} ({sizeStr}, {fileType}{versionStr})" - ); - } - else + RpfFile? child = rpf.Children + .FirstOrDefault(c => c.Name == fileEntry.Name && c.Root != null); + + if (child != null) { - Console.WriteLine($"{prefix}{connector}{name}"); + items.Add(new ChildItem(fileEntry.Name, true, child.Root, child, fileEntry)); + _ = expandedRpfs.Add(fileEntry.Name); } } } + + if (dir.Files == null) + return items; + + // Add files (matching filters, skip RPFs already expanded as directories) + items.AddRange(dir.Files + .Where(fe => + !expandedRpfs.Contains(fe.Name) + && Filter.Matches(fe.Path, options.Rpf.Filters) + ) + .Select(fe => new ChildItem(fe.Name, false, fe, null))); + + return items; } - private static Json.TreeNode BuildTreeNode( + internal static Json.TreeNode BuildTreeNode( RpfDirectoryEntry dir, RpfFile rpf, TreeOptions options, @@ -229,27 +193,45 @@ CancellationToken cancellationToken if (options.Depth < 0 || depth < options.Depth) { - List<(string name, bool isDir, RpfEntry entry, RpfFile? childRpf)> items = - CollectChildren(dir, rpf, options); - - foreach ((string name, bool isDirectory, RpfEntry entry, RpfFile? childRpf) in items) + foreach (ChildItem item in CollectChildren(dir, rpf, options)) { - if (isDirectory) + if (item.IsDir) { + if (item.Entry is not RpfDirectoryEntry subDir) + continue; + + Json.TreeNode dirNode = BuildTreeNode( + subDir, + item.ChildRpf ?? rpf, + options, + depth + 1, + ref totalFiles, + ref totalDirs, + cancellationToken + ); + + // Prune empty directories when filters are active + if (options.Rpf.Filters.Length > 0 + && (dirNode.Children == null || dirNode.Children.Count == 0)) + { + continue; + } + totalDirs++; - if (entry is RpfDirectoryEntry subDir) + if (item.ArchiveEntry != null) + { + long archiveSize = item.ArchiveEntry.GetFileSize(); + children.Add(dirNode with + { + Name = item.Name, + Size = archiveSize, + SizeFormatted = options.Rpf.SizeFormat.ToFormattedString(archiveSize), + FileType = RpfService.GetFileType(item.ArchiveEntry) + }); + } + else { - children.Add( - BuildTreeNode( - subDir, - childRpf ?? rpf, - options, - depth + 1, - ref totalFiles, - ref totalDirs, - cancellationToken - ) - ); + children.Add(dirNode); } } else @@ -258,23 +240,27 @@ CancellationToken cancellationToken long? size = null; string? sizeFormatted = null; string? fileType = null; + int? version = null; - if (entry is RpfFileEntry fileEntry) + if (item.Entry is RpfFileEntry fileEntry) { size = fileEntry.GetFileSize(); sizeFormatted = options.Rpf.SizeFormat.ToFormattedString(size.Value); fileType = RpfService.GetFileType(fileEntry); + if (fileEntry is RpfResourceFileEntry rfe) + version = rfe.Version; } children.Add( new Json.TreeNode { - Name = name, - Path = entry.Path, + Name = item.Name, + Path = item.Entry.Path, Type = "file", Size = size, SizeFormatted = sizeFormatted, FileType = fileType, + Version = version } ); } @@ -286,55 +272,82 @@ CancellationToken cancellationToken Name = dir.Name ?? Path.GetFileName(rpf.FilePath), Path = dir.Path ?? rpf.Path, Type = "dir", - Children = children, + Children = children }; } - private static List<( - string name, - bool isDir, - RpfEntry entry, - RpfFile? childRpf - )> CollectChildren(RpfDirectoryEntry dir, RpfFile rpf, TreeOptions options) + internal static void PrintTree( + Json.TreeNode root, + int totalFiles, + int totalDirs, + TreeOptions options, + CancellationToken cancellationToken) { - List<(string name, bool isDir, RpfEntry entry, RpfFile? childRpf)> items = []; + Console.WriteLine(root.Name); + PrintTreeChildren(root, "", options, cancellationToken); + Console.Error.WriteLine(); + Console.Error.WriteLine($"{totalDirs} directories, {totalFiles} files"); + } - // Add subdirectories - if (dir.Directories != null) + internal static void PrintTreeChildren( + Json.TreeNode node, + string prefix, + TreeOptions options, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (node.Children == null) + return; + + for (int i = 0; i < node.Children.Count; i++) { - foreach (RpfDirectoryEntry subDir in dir.Directories) + bool isLast = i == node.Children.Count - 1; + string connector = isLast ? "\u2514\u2500\u2500 " : "\u251c\u2500\u2500 "; + string childPrefix = prefix + (isLast ? " " : "\u2502 "); + + Json.TreeNode child = node.Children[i]; + + if (child.Type == "dir") { - items.Add((subDir.Name, true, subDir, null)); + if (options.Rpf.Verbose && child.SizeFormatted != null) + Console.WriteLine($"{prefix}{connector}{child.Name}/ <{child.SizeFormatted}, {child.FileType}>"); + else + Console.WriteLine($"{prefix}{connector}{child.Name}/"); + PrintTreeChildren(child, childPrefix, options, cancellationToken); } - } - - // Add nested RPFs as directories if recursive - if (options.Rpf.Recursive && dir.Files != null && rpf.Children != null) - { - foreach (RpfFileEntry fileEntry in dir.Files) + else if (options.Rpf.Verbose && child.SizeFormatted != null) { - if (!fileEntry.NameLower.EndsWith(".rpf", StringComparison.Ordinal)) - continue; - - RpfFile? child = rpf.Children.FirstOrDefault(c => c.Name == fileEntry.Name && c.Root != null); - if (child != null) - { - items.Add((fileEntry.Name, true, child.Root, child)); - } + string versionStr = child.Version != null ? $" v{child.Version}" : ""; + Console.WriteLine( + $"{prefix}{connector}{child.Name} ({child.SizeFormatted}, {child.FileType}{versionStr})" + ); + } + else + { + Console.WriteLine($"{prefix}{connector}{child.Name}"); } } + } - // Add files (non-RPF, matching filters) - if (dir.Files != null) + internal static void PrintJsonTree( + Json.TreeNode root, + int totalFiles, + int totalDirs, + List scanErrors, + TreeOptions options) + { + Json.TreeResult result = new() { - items.AddRange( - dir.Files - .Where(fe => !fe.NameLower.EndsWith(".rpf", StringComparison.Ordinal) - && Filter.Matches(fe.Path, options.Rpf.Filters)) - .Select(fe => (fe.Name, false, (RpfEntry)fe, (RpfFile?)null)) - ); - } + Success = scanErrors.Count == 0, + RpfFile = options.Rpf.RpfPath, + TotalFiles = totalFiles, + TotalDirs = totalDirs, + Root = root, + ErrorMessages = [.. scanErrors] + }; - return items; + Console.WriteLine( + JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + ); } } diff --git a/CodeWalker.Cli/Handlers/ValidateHandler.cs b/CodeWalker.Cli/Handlers/ValidateHandler.cs index 36dc2560d..2fc77734b 100644 --- a/CodeWalker.Cli/Handlers/ValidateHandler.cs +++ b/CodeWalker.Cli/Handlers/ValidateHandler.cs @@ -75,9 +75,9 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => return RpfService.ReportError(initError, options.Rpf.Json, ErrorResult([])); } + List scanErrors = []; try { - List scanErrors = []; RpfFile rpf = RpfService.OpenRpf( options.Rpf.RpfPath, options.Rpf.Verbose, @@ -212,7 +212,7 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => return RpfService.ReportError( ex.Message, options.Rpf.Json, - ErrorResult([]), + ErrorResult([.. scanErrors]), options.Rpf.Verbose ? ex.StackTrace : null ); } diff --git a/CodeWalker.Cli/Json/TreeResult.cs b/CodeWalker.Cli/Json/TreeResult.cs index 7b203f3a5..0992f7493 100644 --- a/CodeWalker.Cli/Json/TreeResult.cs +++ b/CodeWalker.Cli/Json/TreeResult.cs @@ -44,6 +44,10 @@ internal sealed record TreeNode [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? FileType { get; init; } + [JsonPropertyName("version")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Version { get; init; } + [JsonPropertyName("children")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IReadOnlyList? Children { get; init; } diff --git a/CodeWalker.Cli/RpfOptions.cs b/CodeWalker.Cli/RpfOptions.cs index d7482aff6..4776892cc 100644 --- a/CodeWalker.Cli/RpfOptions.cs +++ b/CodeWalker.Cli/RpfOptions.cs @@ -51,10 +51,10 @@ internal sealed class RpfCommandOptions Description = "Process nested RPF archives", }; - public void AddTo(Command command) + public void AddTo(Command command, bool includeThreads = true) { command.Add(this.Rpf); - this._commonOpts.AddTo(command); + this._commonOpts.AddTo(command, includeThreads); command.Add(this.Gen9); command.Add(this.Filter); command.Add(this.Recursive); diff --git a/CodeWalker.Cli/Tests/Handlers/ListHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/ListHandlerTests.cs index a6764a158..992f0bfff 100644 --- a/CodeWalker.Cli/Tests/Handlers/ListHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/ListHandlerTests.cs @@ -1,8 +1,12 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Text.Json; +using System.Threading; using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; using Xunit; @@ -11,20 +15,48 @@ namespace CodeWalker.Cli.Tests.Handlers; [Collection("ConsoleOutput")] public sealed class ListHandlerTests { - private static RpfOptions MakeOptions(string rpfPath, bool json) => + private static RpfOptions MakeOptions( + string rpfPath = "/test/test.rpf", + bool json = false, + bool verbose = false, + SizeFormat sizeFormat = SizeFormat.IEC) => new() { RpfPath = rpfPath, ExePath = "/nonexistent", Gen9 = false, Filters = [], - Verbose = false, + Verbose = verbose, Json = json, Recursive = false, Threads = 1, - SizeFormat = SizeFormat.IEC, + SizeFormat = sizeFormat, }; + private static readonly char[] SplitChars = ['\r', '\n']; + + private static RpfBinaryFileEntry MakeBinary(string name, string path, uint fileSize) => + new() + { + Name = name, + NameLower = name.ToLowerInvariant(), + Path = path, + FileSize = fileSize, + FileUncompressedSize = fileSize, + }; + + private static RpfResourceFileEntry MakeResource(string name, string path, uint fileSize) => + new() + { + Name = name, + NameLower = name.ToLowerInvariant(), + Path = path, + FileSize = fileSize, + }; + + private static RpfFile MakeRpf(uint grandTotalRpfCount = 1) => + new("test.rpf", "test.rpf", 0) { GrandTotalRpfCount = grandTotalRpfCount }; + // ── Validation failures ──────────────────────────────────────────── [Fact] @@ -38,7 +70,7 @@ public void Execute_MissingRpf_ReturnsOne() Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = ListHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), TestContext.Current.CancellationToken); + int exitCode = ListHandler.Execute(MakeOptions("/nonexistent/test.rpf"), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); @@ -79,10 +111,12 @@ public void Execute_MissingRpf_Json_ReturnsErrorJson() public void Execute_Json_ErrorContainsExpectedFields() { TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; try { StringWriter stdout = new(); Console.SetOut(stdout); + Console.SetError(new StringWriter()); _ = ListHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); @@ -93,7 +127,11 @@ public void Execute_Json_ErrorContainsExpectedFields() Assert.Contains("\"totalSizeFormatted\": \"0 B\"", output); Assert.Contains("\"nestedRpfCount\": 0", output); } - finally { Console.SetOut(origOut); } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } } [Fact] @@ -113,7 +151,7 @@ public void Execute_MissingExe_WithExistingRpf_ReturnsOne() StringWriter stderr = new(); Console.SetError(stderr); - int exitCode = ListHandler.Execute(MakeOptions(rpf, json: false), TestContext.Current.CancellationToken); + int exitCode = ListHandler.Execute(MakeOptions(rpf), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); @@ -126,4 +164,530 @@ public void Execute_MissingExe_WithExistingRpf_ReturnsOne() } finally { Directory.Delete(dir, true); } } + + // ── CollectList ──────────────────────────────────────────────────── + + [Fact] + public void CollectList_EmptyEntries_ReturnsZeroTotals() + { + Json.ListResult result = ListHandler.CollectList( + [], + MakeRpf(), + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.True(result.Success); + Assert.Equal(0, result.TotalFiles); + Assert.Equal(0L, result.TotalSize); + Assert.Equal("0 B", result.TotalSizeFormatted); + Assert.Empty(result.Files); + Assert.Empty(result.ErrorMessages); + } + + [Fact] + public void CollectList_BinaryEntries_SumsCorrectly() + { + RpfFile rpf = MakeRpf(grandTotalRpfCount: 3); + RpfBinaryFileEntry e1 = MakeBinary("data.dat", "common\\data.dat", 1024); + RpfBinaryFileEntry e2 = MakeBinary("info.bin", "common\\info.bin", 2048); + + List<(RpfFile rpf, RpfFileEntry entry)> entries = [(rpf, e1), (rpf, e2)]; + + Json.ListResult result = ListHandler.CollectList( + entries, + rpf, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.True(result.Success); + Assert.Equal(2, result.TotalFiles); + Assert.Equal(3072L, result.TotalSize); + Assert.Equal(SizeFormat.IEC.ToFormattedString(3072), result.TotalSizeFormatted); + Assert.Equal(3L, result.NestedRpfCount); + Assert.Empty(result.ErrorMessages); + } + + [Fact] + public void CollectList_FileEntry_HasCorrectFields() + { + RpfFile rpf = MakeRpf(); + RpfBinaryFileEntry entry = MakeBinary("data.dat", "common\\data.dat", 512); + + Json.ListResult result = ListHandler.CollectList( + [(rpf, entry)], + rpf, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + _ = Assert.Single(result.Files); + Json.FileEntry file = result.Files[0]; + Assert.Equal("common\\data.dat", file.Path); + Assert.Equal("data.dat", file.Name); + Assert.Equal(512L, file.Size); + Assert.Equal(SizeFormat.IEC.ToFormattedString(512), file.SizeFormatted); + Assert.Equal("binary", file.Type); + Assert.Equal(".dat", file.Extension); + } + + [Fact] + public void CollectList_ResourceEntry_TypeIsResource() + { + RpfFile rpf = MakeRpf(); + RpfResourceFileEntry entry = MakeResource("model.ydr", "x64\\model.ydr", 4096); + + Json.ListResult result = ListHandler.CollectList( + [(rpf, entry)], + rpf, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + _ = Assert.Single(result.Files); + Assert.Equal("resource", result.Files[0].Type); + Assert.Equal(".ydr", result.Files[0].Extension); + } + + [Fact] + public void CollectList_SIFormat_UsesCorrectFormatting() + { + RpfFile rpf = MakeRpf(); + RpfBinaryFileEntry entry = MakeBinary("data.dat", "common\\data.dat", 2000); + + Json.ListResult result = ListHandler.CollectList( + [(rpf, entry)], + rpf, + [], + MakeOptions(sizeFormat: SizeFormat.SI), + TestContext.Current.CancellationToken + ); + + Assert.Equal(SizeFormat.SI.ToFormattedString(2000), result.TotalSizeFormatted); + Assert.Equal(SizeFormat.SI.ToFormattedString(2000), result.Files[0].SizeFormatted); + } + + [Fact] + public void CollectList_WithScanErrors_SetsSuccessFalse() + { + RpfFile rpf = MakeRpf(); + List scanErrors = ["scan error 1", "scan error 2"]; + + Json.ListResult result = ListHandler.CollectList( + [], + rpf, + scanErrors, + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.False(result.Success); + Assert.Equal(2, result.ErrorMessages.Count); + Assert.Equal("scan error 1", result.ErrorMessages[0]); + Assert.Equal("scan error 2", result.ErrorMessages[1]); + } + + [Fact] + public void CollectList_NoExtension_ReturnsEmptyExtension() + { + RpfFile rpf = MakeRpf(); + RpfBinaryFileEntry entry = MakeBinary("README", "common\\README", 100); + + Json.ListResult result = ListHandler.CollectList( + [(rpf, entry)], + rpf, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.Equal("", result.Files[0].Extension); + } + + [Fact] + public void CollectList_Cancelled_ThrowsOperationCanceledException() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + + RpfFile rpf = MakeRpf(); + RpfBinaryFileEntry entry = MakeBinary("data.dat", "common\\data.dat", 100); + + _ = Assert.Throws( + () => ListHandler.CollectList( + [(rpf, entry)], + rpf, + [], + MakeOptions(), + cts.Token + ) + ); + } + + // ── ErrorResult ──────────────────────────────────────────────────── + + [Fact] + public void ErrorResult_HasExpectedDefaults() + { + Json.ListResult result = ListHandler.ErrorResult([], MakeOptions("/some/path.rpf")); + + Assert.False(result.Success); + Assert.Equal("/some/path.rpf", result.RpfFile); + Assert.Equal(0, result.TotalFiles); + Assert.Equal(0L, result.TotalSize); + Assert.Equal("0 B", result.TotalSizeFormatted); + Assert.Equal(0L, result.NestedRpfCount); + Assert.Empty(result.Files); + Assert.Empty(result.ErrorMessages); + } + + [Fact] + public void ErrorResult_PreservesErrorMessages() + { + string[] errors = ["err1", "err2"]; + + Json.ListResult result = ListHandler.ErrorResult(errors, MakeOptions()); + + Assert.Equal(2, result.ErrorMessages.Count); + Assert.Equal("err1", result.ErrorMessages[0]); + Assert.Equal("err2", result.ErrorMessages[1]); + } + + // ── PrintList (text output) ──────────────────────────────────────── + + [Fact] + public void PrintList_NonVerbose_PrintsPathsOnly() + { + Json.ListResult result = new() + { + Success = true, + RpfFile = "test.rpf", + TotalFiles = 2, + TotalSize = 3072, + TotalSizeFormatted = "3 KiB", + NestedRpfCount = 1, + Files = + [ + new Json.FileEntry { Path = "common\\data.dat", Name = "data.dat", Size = 1024, SizeFormatted = "1 KiB", Type = "binary", Extension = ".dat" }, + new Json.FileEntry { Path = "common\\info.bin", Name = "info.bin", Size = 2048, SizeFormatted = "2 KiB", Type = "binary", Extension = ".bin" }, + ], + ErrorMessages = [], + }; + + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + ListHandler.PrintList(result, MakeOptions(), TestContext.Current.CancellationToken); + + string output = stdout.ToString(); + string[] lines = output.Split(SplitChars, StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(2, lines.Length); + Assert.Equal("common\\data.dat", lines[0]); + Assert.Equal("common\\info.bin", lines[1]); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintList_Verbose_PrintsSizeAndPath() + { + Json.ListResult result = new() + { + Success = true, + RpfFile = "test.rpf", + TotalFiles = 1, + TotalSize = 1024, + TotalSizeFormatted = "1 KiB", + NestedRpfCount = 1, + Files = + [ + new Json.FileEntry { Path = "common\\data.dat", Name = "data.dat", Size = 1024, SizeFormatted = "1 KiB", Type = "binary", Extension = ".dat" }, + ], + ErrorMessages = [], + }; + + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + ListHandler.PrintList(result, MakeOptions(verbose: true), TestContext.Current.CancellationToken); + + string output = stdout.ToString(); + Assert.Contains("1 KiB", output); + Assert.Contains("common\\data.dat", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintList_Verbose_SizeIsPaddedTo12Chars() + { + Json.ListResult result = new() + { + Success = true, + RpfFile = "test.rpf", + TotalFiles = 1, + TotalSize = 100, + TotalSizeFormatted = "100 B", + NestedRpfCount = 1, + Files = + [ + new Json.FileEntry { Path = "a.dat", Name = "a.dat", Size = 100, SizeFormatted = "100 B", Type = "binary", Extension = ".dat" }, + ], + ErrorMessages = [], + }; + + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + ListHandler.PrintList(result, MakeOptions(verbose: true), TestContext.Current.CancellationToken); + + string[] lines = stdout.ToString().Split(SplitChars, StringSplitOptions.RemoveEmptyEntries); + _ = Assert.Single(lines); + // "100 B" (5 chars) padded left to 12 = 7 spaces + "100 B" + " " + path + Assert.Equal(" 100 B a.dat", lines[0]); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintList_WritesSummaryToStderr() + { + Json.ListResult result = new() + { + Success = true, + RpfFile = "test.rpf", + TotalFiles = 5, + TotalSize = 10240, + TotalSizeFormatted = "10 KiB", + NestedRpfCount = 1, + Files = [], + ErrorMessages = [], + }; + + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + StringWriter stderr = new(); + Console.SetError(stderr); + + ListHandler.PrintList(result, MakeOptions(), TestContext.Current.CancellationToken); + + string errOutput = stderr.ToString(); + Assert.Contains("Total: 5 files, 10 KiB", errOutput); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintList_Cancelled_ThrowsOperationCanceledException() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + + Json.ListResult result = new() + { + Success = true, + RpfFile = "test.rpf", + TotalFiles = 1, + TotalSize = 100, + TotalSizeFormatted = "100 B", + NestedRpfCount = 1, + Files = + [ + new Json.FileEntry { Path = "a.dat", Name = "a.dat", Size = 100, SizeFormatted = "100 B", Type = "binary", Extension = ".dat" }, + ], + ErrorMessages = [], + }; + + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + Console.SetError(new StringWriter()); + + _ = Assert.Throws( + () => ListHandler.PrintList(result, MakeOptions(), cts.Token) + ); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + // ── PrintJsonList ────────────────────────────────────────────────── + + [Fact] + public void PrintJsonList_SerializesToStdout() + { + Json.ListResult result = new() + { + Success = true, + RpfFile = "test.rpf", + TotalFiles = 1, + TotalSize = 512, + TotalSizeFormatted = "512 B", + NestedRpfCount = 1, + Files = + [ + new Json.FileEntry { Path = "common\\data.dat", Name = "data.dat", Size = 512, SizeFormatted = "512 B", Type = "binary", Extension = ".dat" }, + ], + ErrorMessages = [], + }; + + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + ListHandler.PrintJsonList(result); + + string output = stdout.ToString(); + Assert.Contains("\"success\": true", output); + Assert.Contains("\"rpfFile\": \"test.rpf\"", output); + Assert.Contains("\"totalFiles\": 1", output); + Assert.Contains("\"totalSize\": 512", output); + Assert.Contains("\"totalSizeFormatted\": \"512 B\"", output); + Assert.Contains("\"nestedRpfCount\": 1", output); + Assert.Contains("\"path\": \"common\\\\data.dat\"", output); + Assert.Contains("\"name\": \"data.dat\"", output); + Assert.Contains("\"size\": 512", output); + Assert.Contains("\"type\": \"binary\"", output); + Assert.Contains("\"extension\": \".dat\"", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void PrintJsonList_IsValidJson() + { + Json.ListResult result = new() + { + Success = true, + RpfFile = "test.rpf", + TotalFiles = 0, + TotalSize = 0, + TotalSizeFormatted = "0 B", + NestedRpfCount = 0, + Files = [], + ErrorMessages = [], + }; + + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + ListHandler.PrintJsonList(result); + + string output = stdout.ToString().Trim(); + JsonDocument doc = JsonDocument.Parse(output); + Assert.Equal(JsonValueKind.Object, doc.RootElement.ValueKind); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void PrintJsonList_OmitsNullStatus() + { + Json.ListResult result = new() + { + Success = true, + RpfFile = "test.rpf", + TotalFiles = 1, + TotalSize = 100, + TotalSizeFormatted = "100 B", + NestedRpfCount = 0, + Files = + [ + new Json.FileEntry { Path = "a.dat", Name = "a.dat", Size = 100, SizeFormatted = "100 B", Type = "binary", Extension = ".dat" }, + ], + ErrorMessages = [], + }; + + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + ListHandler.PrintJsonList(result); + + string output = stdout.ToString(); + Assert.DoesNotContain("\"status\"", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void PrintJsonList_IncludesErrorMessages() + { + Json.ListResult result = new() + { + Success = false, + RpfFile = "test.rpf", + TotalFiles = 0, + TotalSize = 0, + TotalSizeFormatted = "0 B", + NestedRpfCount = 0, + Files = [], + ErrorMessages = ["something broke"], + }; + + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + ListHandler.PrintJsonList(result); + + string output = stdout.ToString(); + Assert.Contains("\"errorMessages\"", output); + Assert.Contains("something broke", output); + } + finally { Console.SetOut(origOut); } + } } diff --git a/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs index bc38789de..31be3154b 100644 --- a/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs @@ -610,7 +610,7 @@ public void PrintJsonStats_ContainsAllFields() Assert.Contains("\"compressedSizeFormatted\":", json); Assert.Contains("\"uncompressedSize\": 1200", json); Assert.Contains("\"uncompressedSizeFormatted\":", json); - Assert.Contains("\"compressionRatio\": 0.6667", json); + Assert.Contains($"\"compressionRatio\": {JsonSerializer.Serialize(0.6667)}", json); Assert.Contains("\"extensions\":", json); } finally { Console.SetOut(orig); } diff --git a/CodeWalker.Cli/Tests/Handlers/TreeHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/TreeHandlerTests.cs index ffadd7463..251ecd467 100644 --- a/CodeWalker.Cli/Tests/Handlers/TreeHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/TreeHandlerTests.cs @@ -1,15 +1,626 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; using Xunit; namespace CodeWalker.Cli.Tests.Handlers; +// ── ErrorResult ────────────────────────────────────────────────────── + +public sealed class TreeErrorResultTests +{ + private static TreeOptions MakeOptions(string rpfPath = "/test.rpf") => + new() + { + Rpf = new RpfOptions + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC + }, + Depth = -1 + }; + + [Fact] + public void ErrorResult_SetsSuccessFalse() + { + Json.TreeResult result = TreeHandler.ErrorResult([], MakeOptions()); + Assert.False(result.Success); + } + + [Fact] + public void ErrorResult_PreservesErrorMessages() + { + string[] msgs = ["err1", "err2"]; + Json.TreeResult result = TreeHandler.ErrorResult(msgs, MakeOptions()); + Assert.Equal(msgs, result.ErrorMessages); + } + + [Fact] + public void ErrorResult_SetsRpfFile() + { + Json.TreeResult result = TreeHandler.ErrorResult([], MakeOptions("/my/test.rpf")); + Assert.Equal("/my/test.rpf", result.RpfFile); + } + + [Fact] + public void ErrorResult_CountsAreZero() + { + Json.TreeResult result = TreeHandler.ErrorResult([], MakeOptions()); + Assert.Equal(0, result.TotalFiles); + Assert.Equal(0, result.TotalDirs); + } + + [Fact] + public void ErrorResult_RootIsNull() + { + Json.TreeResult result = TreeHandler.ErrorResult([], MakeOptions()); + Assert.Null(result.Root); + } +} + +// ── PrintTree (text) ───────────────────────────────────────────────── + +[Collection("ConsoleOutput")] +public sealed class PrintTreeTests +{ + private static readonly char[] NewLineSeparator = ['\n']; + private static TreeOptions MakeOptions(bool verbose = false) => + new() + { + Rpf = new RpfOptions + { + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = verbose, + Json = false, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC + }, + Depth = -1 + }; + + private static Json.TreeNode MakeFileNode( + string name, + string path = "", + long? size = null, + string? sizeFormatted = null, + string? fileType = null, + int? version = null) => + new() + { + Name = name, + Path = path, + Type = "file", + Size = size, + SizeFormatted = sizeFormatted, + FileType = fileType, + Version = version + }; + + private static Json.TreeNode MakeDirNode( + string name, + string path = "", + IReadOnlyList? children = null) => + new() + { + Name = name, + Path = path, + Type = "dir", + Children = children ?? [] + }; + + private static (string stdout, string stderr) Capture( + Json.TreeNode root, + int totalFiles, + int totalDirs, + TreeOptions? options = null, + CancellationToken? cancellationToken = null) + { + options ??= MakeOptions(); + CancellationToken ct = cancellationToken ?? TestContext.Current.CancellationToken; + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter sw = new(); + StringWriter se = new(); + Console.SetOut(sw); + Console.SetError(se); + TreeHandler.PrintTree(root, totalFiles, totalDirs, options, ct); + return (sw.ToString(), se.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintTree_RootNameOnFirstLine() + { + Json.TreeNode root = MakeDirNode("test.rpf/"); + (string stdout, _) = Capture(root, 0, 0); + string firstLine = stdout.Split('\n')[0].TrimEnd('\r'); + Assert.Equal("test.rpf/", firstLine); + } + + [Fact] + public void PrintTree_SummaryOnStderr() + { + Json.TreeNode root = MakeDirNode("test.rpf/"); + (_, string stderr) = Capture(root, 5, 2); + Assert.Contains("2 directories, 5 files", stderr); + } + + [Fact] + public void PrintTree_SingleFile_ShowsConnector() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeFileNode("data.dat") + ]); + (string stdout, _) = Capture(root, 1, 0); + Assert.Contains("\u2514\u2500\u2500 data.dat", stdout); + } + + [Fact] + public void PrintTree_MultipleFiles_ShowsBranchAndLastConnectors() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeFileNode("a.dat"), + MakeFileNode("b.dat"), + MakeFileNode("c.dat") + ]); + (string stdout, _) = Capture(root, 3, 0); + // First two get ├──, last gets └── + Assert.Contains("\u251c\u2500\u2500 a.dat", stdout); + Assert.Contains("\u251c\u2500\u2500 b.dat", stdout); + Assert.Contains("\u2514\u2500\u2500 c.dat", stdout); + } + + [Fact] + public void PrintTree_Directory_ShowsTrailingSlash() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeDirNode("subdir") + ]); + (string stdout, _) = Capture(root, 0, 1); + Assert.Contains("\u2514\u2500\u2500 subdir/", stdout); + } + + [Fact] + public void PrintTree_NestedStructure_ShowsIndentation() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeDirNode("subdir", children: + [ + MakeFileNode("nested.dat") + ]), + MakeFileNode("top.dat") + ]); + (string stdout, _) = Capture(root, 2, 1); + // subdir gets ├── (not last), nested.dat gets │ └── + Assert.Contains("\u251c\u2500\u2500 subdir/", stdout); + Assert.Contains("\u2502 \u2514\u2500\u2500 nested.dat", stdout); + Assert.Contains("\u2514\u2500\u2500 top.dat", stdout); + } + + [Fact] + public void PrintTree_LastDirectory_UsesSpacePrefix() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeDirNode("lastdir", children: + [ + MakeFileNode("child.dat") + ]) + ]); + (string stdout, _) = Capture(root, 1, 1); + // lastdir is last child → └──, its children use " " (4 spaces) prefix + Assert.Contains("\u2514\u2500\u2500 lastdir/", stdout); + Assert.Contains(" \u2514\u2500\u2500 child.dat", stdout); + } + + [Fact] + public void PrintTree_Verbose_ShowsSizeAndType() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeFileNode("model.ydr", size: 1024, sizeFormatted: "1.0 KiB", fileType: "Resource") + ]); + (string stdout, _) = Capture(root, 1, 0, MakeOptions(verbose: true)); + Assert.Contains("model.ydr (1.0 KiB, Resource)", stdout); + } + + [Fact] + public void PrintTree_Verbose_ShowsVersion() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeFileNode("model.ydr", size: 1024, sizeFormatted: "1.0 KiB", fileType: "Resource", version: 110) + ]); + (string stdout, _) = Capture(root, 1, 0, MakeOptions(verbose: true)); + Assert.Contains("model.ydr (1.0 KiB, Resource v110)", stdout); + } + + [Fact] + public void PrintTree_NonVerbose_HidesSizeAndType() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeFileNode("model.ydr", size: 1024, sizeFormatted: "1.0 KiB", fileType: "Resource") + ]); + (string stdout, _) = Capture(root, 1, 0, MakeOptions(verbose: false)); + Assert.Contains("model.ydr", stdout); + Assert.DoesNotContain("1.0 KiB", stdout); + Assert.DoesNotContain("Resource", stdout); + } + + [Fact] + public void PrintTree_Verbose_ArchiveDir_ShowsSizeAndType() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + new Json.TreeNode + { + Name = "nested.rpf", + Path = "/test.rpf/nested.rpf", + Type = "dir", + Size = 2048, + SizeFormatted = "2.0 KiB", + FileType = "binary", + Children = [MakeFileNode("inner.dat")] + } + ]); + (string stdout, _) = Capture(root, 1, 1, MakeOptions(verbose: true)); + Assert.Contains("nested.rpf/ <2.0 KiB, binary>", stdout); + } + + [Fact] + public void PrintTree_NonVerbose_ArchiveDir_HidesSizeAndType() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + new Json.TreeNode + { + Name = "nested.rpf", + Path = "/test.rpf/nested.rpf", + Type = "dir", + Size = 2048, + SizeFormatted = "2.0 KiB", + FileType = "binary", + Children = [MakeFileNode("inner.dat")] + } + ]); + (string stdout, _) = Capture(root, 1, 1, MakeOptions(verbose: false)); + Assert.Contains("nested.rpf/", stdout); + Assert.DoesNotContain("2.0 KiB", stdout); + } + + [Fact] + public void PrintTree_EmptyRoot_ShowsOnlyRootName() + { + Json.TreeNode root = MakeDirNode("empty.rpf/"); + (string stdout, string stderr) = Capture(root, 0, 0); + string firstLine = stdout.Split('\n')[0].TrimEnd('\r'); + Assert.Equal("empty.rpf/", firstLine); + Assert.Contains("0 directories, 0 files", stderr); + } + + [Fact] + public void PrintTree_NullChildren_NoOutput() + { + Json.TreeNode root = new() + { + Name = "test.rpf/", + Path = "", + Type = "dir", + Children = null + }; + (string stdout, _) = Capture(root, 0, 0); + string[] lines = stdout.Split(NewLineSeparator, StringSplitOptions.RemoveEmptyEntries); + _ = Assert.Single(lines); // Only the root name + } +} + +// ── PrintJsonTree ──────────────────────────────────────────────────── + [Collection("ConsoleOutput")] -public sealed class TreeHandlerTests +public sealed class PrintJsonTreeTests +{ + private static TreeOptions MakeOptions(string rpfPath = "/test.rpf") => + new() + { + Rpf = new RpfOptions + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = true, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC + }, + Depth = -1 + }; + + private static Json.TreeNode MakeFileNode( + string name, + string path = "", + long? size = null, + string? sizeFormatted = null, + string? fileType = null, + int? version = null) => + new() + { + Name = name, + Path = path, + Type = "file", + Size = size, + SizeFormatted = sizeFormatted, + FileType = fileType, + Version = version + }; + + private static Json.TreeNode MakeDirNode( + string name, + string path = "", + IReadOnlyList? children = null) => + new() + { + Name = name, + Path = path, + Type = "dir", + Children = children ?? [] + }; + + private static string CaptureJson( + Json.TreeNode root, + int totalFiles, + int totalDirs, + List? scanErrors = null, + TreeOptions? options = null) + { + options ??= MakeOptions(); + scanErrors ??= []; + TextWriter origOut = Console.Out; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + TreeHandler.PrintJsonTree(root, totalFiles, totalDirs, scanErrors, options); + return sw.ToString(); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void PrintJsonTree_WritesValidJson() + { + Json.TreeNode root = MakeDirNode("test.rpf/"); + string json = CaptureJson(root, 0, 0); + + Json.TreeResult? parsed = JsonSerializer.Deserialize( + json.Trim(), RpfService.JsonSerializerOptions + ); + Assert.NotNull(parsed); + } + + [Fact] + public void PrintJsonTree_ContainsAllTopLevelFields() + { + Json.TreeNode root = MakeDirNode("test.rpf/"); + string json = CaptureJson(root, 5, 2); + + Assert.Contains("\"success\": true", json); + Assert.Contains("\"rpfFile\": \"/test.rpf\"", json); + Assert.Contains("\"totalFiles\": 5", json); + Assert.Contains("\"totalDirs\": 2", json); + Assert.Contains("\"root\":", json); + Assert.Contains("\"errorMessages\": []", json); + } + + [Fact] + public void PrintJsonTree_RootNodeHasNamePathType() + { + Json.TreeNode root = MakeDirNode("test.rpf/", path: "/test.rpf"); + string json = CaptureJson(root, 0, 0); + + Assert.Contains("\"name\": \"test.rpf/\"", json); + Assert.Contains("\"path\": \"/test.rpf\"", json); + Assert.Contains("\"type\": \"dir\"", json); + } + + [Fact] + public void PrintJsonTree_FileNodeIncludesSizeFields() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeFileNode("model.ydr", path: "model.ydr", size: 1024, sizeFormatted: "1.0 KiB", fileType: "Resource", version: 110) + ]); + string json = CaptureJson(root, 1, 0); + + Assert.Contains("\"size\": 1024", json); + Assert.Contains("\"sizeFormatted\": \"1.0 KiB\"", json); + Assert.Contains("\"fileType\": \"Resource\"", json); + Assert.Contains("\"version\": 110", json); + } + + [Fact] + public void PrintJsonTree_FileNodeOmitsNullOptionalFields() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeFileNode("data.dat", path: "data.dat") + ]); + string json = CaptureJson(root, 1, 0); + + // These should be omitted (JsonIgnore WhenWritingNull) + Assert.DoesNotContain("\"size\":", json); + Assert.DoesNotContain("\"sizeFormatted\":", json); + Assert.DoesNotContain("\"fileType\":", json); + Assert.DoesNotContain("\"version\":", json); + } + + [Fact] + public void PrintJsonTree_ScanErrors_SetsSuccessFalse() + { + Json.TreeNode root = MakeDirNode("test.rpf/"); + string json = CaptureJson(root, 0, 0, scanErrors: ["scan failed"]); + + Assert.Contains("\"success\": false", json); + Assert.Contains("scan failed", json); + } + + [Fact] + public void PrintJsonTree_RoundTripsCorrectly() + { + Json.TreeNode root = MakeDirNode("test.rpf/", path: "/test.rpf", children: + [ + MakeDirNode("subdir", path: "/test.rpf/subdir", children: + [ + MakeFileNode("a.dat", path: "/test.rpf/subdir/a.dat", size: 100, sizeFormatted: "100 B", fileType: "Binary") + ]), + MakeFileNode("b.ydr", path: "/test.rpf/b.ydr", size: 500, sizeFormatted: "500 B", fileType: "Resource", version: 110) + ]); + string json = CaptureJson(root, 2, 1); + + Json.TreeResult? parsed = JsonSerializer.Deserialize( + json.Trim(), RpfService.JsonSerializerOptions + ); + Assert.NotNull(parsed); + Assert.True(parsed.Success); + Assert.Equal(2, parsed.TotalFiles); + Assert.Equal(1, parsed.TotalDirs); + Assert.NotNull(parsed.Root); + Assert.Equal("test.rpf/", parsed.Root.Name); + Assert.NotNull(parsed.Root.Children); + Assert.Equal(2, parsed.Root.Children.Count); + } + + [Fact] + public void PrintJsonTree_PreservesRpfPath() + { + Json.TreeNode root = MakeDirNode("test.rpf/"); + string json = CaptureJson(root, 0, 0, options: MakeOptions("/custom/path.rpf")); + Assert.Contains("\"rpfFile\": \"/custom/path.rpf\"", json); + } +} + +// ── PrintTreeChildren cancellation ─────────────────────────────────── + +[Collection("ConsoleOutput")] +public sealed class PrintTreeChildrenCancellationTests +{ + private static TreeOptions MakeOptions() => + new() + { + Rpf = new RpfOptions + { + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC + }, + Depth = -1 + }; + + [Fact] + public void PrintTreeChildren_Cancelled_ThrowsOperationCanceledException() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + + Json.TreeNode node = new() + { + Name = "root", + Path = "", + Type = "dir", + Children = + [ + new Json.TreeNode { Name = "a.dat", Path = "", Type = "file" } + ] + }; + + TextWriter origOut = Console.Out; + try + { + Console.SetOut(new StringWriter()); + _ = Assert.Throws( + () => TreeHandler.PrintTreeChildren(node, "", MakeOptions(), cts.Token) + ); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void PrintTree_Cancelled_ThrowsOperationCanceledException() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + + Json.TreeNode root = new() + { + Name = "test.rpf/", + Path = "", + Type = "dir", + Children = + [ + new Json.TreeNode { Name = "a.dat", Path = "", Type = "file" } + ] + }; + + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + Console.SetError(new StringWriter()); + _ = Assert.Throws( + () => TreeHandler.PrintTree(root, 1, 0, MakeOptions(), cts.Token) + ); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } +} + +// ── Execute (integration) ──────────────────────────────────────────── + +[Collection("ConsoleOutput")] +public sealed class TreeExecuteTests { private static TreeOptions MakeOptions(string rpfPath, bool json, int depth = -1) => new() @@ -24,9 +635,9 @@ private static TreeOptions MakeOptions(string rpfPath, bool json, int depth = -1 Json = json, Recursive = false, Threads = 1, - SizeFormat = SizeFormat.IEC, + SizeFormat = SizeFormat.IEC }, - Depth = depth, + Depth = depth }; // ── Validation failures ──────────────────────────────────────────── @@ -42,7 +653,10 @@ public void Execute_MissingRpf_ReturnsOne() Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = TreeHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), TestContext.Current.CancellationToken); + int exitCode = TreeHandler.Execute( + MakeOptions("/nonexistent/test.rpf", json: false), + TestContext.Current.CancellationToken + ); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); @@ -65,7 +679,10 @@ public void Execute_MissingRpf_Json_ReturnsErrorJson() Console.SetOut(stdout); Console.SetError(new StringWriter()); - int exitCode = TreeHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); + int exitCode = TreeHandler.Execute( + MakeOptions("/nonexistent/test.rpf", json: true), + TestContext.Current.CancellationToken + ); Assert.Equal(1, exitCode); string output = stdout.ToString(); @@ -88,12 +705,16 @@ public void Execute_Json_ErrorContainsExpectedFields() StringWriter stdout = new(); Console.SetOut(stdout); - _ = TreeHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); + _ = TreeHandler.Execute( + MakeOptions("/nonexistent/test.rpf", json: true), + TestContext.Current.CancellationToken + ); string output = stdout.ToString(); Assert.Contains("\"rpfFile\":", output); Assert.Contains("\"totalFiles\": 0", output); Assert.Contains("\"totalDirs\": 0", output); + Assert.Contains("\"root\": null", output); } finally { Console.SetOut(origOut); } } @@ -130,3 +751,583 @@ public void Execute_MissingExe_WithExistingRpf_ReturnsOne() finally { Directory.Delete(dir, true); } } } + +// ── CollectChildren ───────────────────────────────────────────────── + +public sealed class CollectChildrenTests +{ + private static TreeOptions MakeOptions( + bool recursive = false, + string[]? filters = null) => + new() + { + Rpf = new RpfOptions + { + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = filters ?? [], + Verbose = false, + Json = false, + Recursive = recursive, + Threads = 1, + SizeFormat = SizeFormat.IEC + }, + Depth = -1 + }; + + private static RpfFile MakeRpf(List? children = null) + { + RpfFile rpf = new("test.rpf", "/test.rpf", 0) + { + Children = children + }; + return rpf; + } + + [Fact] + public void EmptyDirectory_ReturnsEmptyList() + { + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Directories = null!, + Files = null! + }; + + List items = TreeHandler.CollectChildren(dir, MakeRpf(), MakeOptions()); + + Assert.Empty(items); + } + + [Fact] + public void EmptyDirectory_WithEmptyLists_ReturnsEmptyList() + { + RpfDirectoryEntry dir = new() { Name = "root", NameLower = "root", Path = "/root" }; + + List items = TreeHandler.CollectChildren(dir, MakeRpf(), MakeOptions()); + + Assert.Empty(items); + } + + [Fact] + public void Subdirectories_ReturnedAsDirItems() + { + RpfDirectoryEntry sub1 = new() { Name = "sub1", NameLower = "sub1", Path = "/root/sub1" }; + RpfDirectoryEntry sub2 = new() { Name = "sub2", NameLower = "sub2", Path = "/root/sub2" }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Directories = [sub1, sub2] + }; + + List items = TreeHandler.CollectChildren(dir, MakeRpf(), MakeOptions()); + + Assert.Equal(2, items.Count); + Assert.All(items, i => Assert.True(i.IsDir)); + Assert.Equal("sub1", items[0].Name); + Assert.Equal("sub2", items[1].Name); + Assert.All(items, i => Assert.Null(i.ChildRpf)); + } + + [Fact] + public void Files_ReturnedAsFileItems() + { + RpfBinaryFileEntry f1 = new() { Name = "a.dat", NameLower = "a.dat", Path = "/root/a.dat" }; + RpfBinaryFileEntry f2 = new() { Name = "b.dat", NameLower = "b.dat", Path = "/root/b.dat" }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [f1, f2] + }; + + List items = TreeHandler.CollectChildren(dir, MakeRpf(), MakeOptions()); + + Assert.Equal(2, items.Count); + Assert.All(items, i => Assert.False(i.IsDir)); + Assert.Equal("a.dat", items[0].Name); + Assert.Equal("b.dat", items[1].Name); + } + + [Fact] + public void NonRecursive_RpfFilesListedAsFiles() + { + RpfBinaryFileEntry rpfFile = new() + { + Name = "nested.rpf", + NameLower = "nested.rpf", + Path = "/root/nested.rpf" + }; + RpfDirectoryEntry childRoot = new() { Name = "nested", NameLower = "nested", Path = "/nested" }; + RpfFile childRpf = new("nested.rpf", "/nested.rpf", 0) { Root = childRoot }; + + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [rpfFile] + }; + + List items = TreeHandler.CollectChildren( + dir, MakeRpf(children: [childRpf]), MakeOptions(recursive: false)); + + _ = Assert.Single(items); + Assert.False(items[0].IsDir); + Assert.Equal("nested.rpf", items[0].Name); + } + + [Fact] + public void Recursive_RpfFilesExpandedAsDirectories() + { + RpfBinaryFileEntry rpfFile = new() + { + Name = "nested.rpf", + NameLower = "nested.rpf", + Path = "/root/nested.rpf" + }; + RpfDirectoryEntry childRoot = new() { Name = "nested", NameLower = "nested", Path = "/nested" }; + RpfFile childRpf = new("nested.rpf", "/nested.rpf", 0) { Root = childRoot }; + + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [rpfFile] + }; + + List items = TreeHandler.CollectChildren( + dir, MakeRpf(children: [childRpf]), MakeOptions(recursive: true)); + + // Expanded as dir + not duplicated as file + _ = Assert.Single(items); + Assert.True(items[0].IsDir); + Assert.Equal("nested.rpf", items[0].Name); + Assert.Same(childRpf, items[0].ChildRpf); + Assert.Same(childRoot, items[0].Entry); + Assert.Same(rpfFile, items[0].ArchiveEntry); + } + + [Fact] + public void Recursive_RpfWithNullRoot_NotExpanded() + { + RpfBinaryFileEntry rpfFile = new() + { + Name = "broken.rpf", + NameLower = "broken.rpf", + Path = "/root/broken.rpf" + }; + RpfFile childRpf = new("broken.rpf", "/broken.rpf", 0); + + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [rpfFile] + }; + + List items = TreeHandler.CollectChildren( + dir, MakeRpf(children: [childRpf]), MakeOptions(recursive: true)); + + // Not expanded (null Root), listed as file instead + _ = Assert.Single(items); + Assert.False(items[0].IsDir); + Assert.Equal("broken.rpf", items[0].Name); + } + + [Fact] + public void FilterMatching_OnlyMatchingFilesIncluded() + { + RpfBinaryFileEntry ydr = new() { Name = "model.ydr", NameLower = "model.ydr", Path = "/root/model.ydr" }; + RpfBinaryFileEntry dat = new() { Name = "data.dat", NameLower = "data.dat", Path = "/root/data.dat" }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [ydr, dat] + }; + + List items = TreeHandler.CollectChildren( + dir, MakeRpf(), MakeOptions(filters: ["*.ydr"])); + + _ = Assert.Single(items); + Assert.Equal("model.ydr", items[0].Name); + } + + [Fact] + public void DirsAndFiles_OrderedCorrectly() + { + RpfDirectoryEntry sub = new() { Name = "subdir", NameLower = "subdir", Path = "/root/subdir" }; + RpfBinaryFileEntry file = new() { Name = "data.dat", NameLower = "data.dat", Path = "/root/data.dat" }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Directories = [sub], + Files = [file] + }; + + List items = TreeHandler.CollectChildren(dir, MakeRpf(), MakeOptions()); + + Assert.Equal(2, items.Count); + Assert.True(items[0].IsDir); // dirs first + Assert.False(items[1].IsDir); // then files + } +} + +// ── BuildTreeNode ─────────────────────────────────────────────────── + +public sealed class BuildTreeNodeTests +{ + private static TreeOptions MakeOptions( + int depth = -1, + string[]? filters = null) => + new() + { + Rpf = new RpfOptions + { + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = filters ?? [], + Verbose = false, + Json = false, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC + }, + Depth = depth + }; + + private static RpfFile MakeRpf() => new("test.rpf", "/test.rpf", 0); + + [Fact] + public void EmptyDirectory_ReturnsDirNodeWithEmptyChildren() + { + RpfDirectoryEntry dir = new() { Name = "root", NameLower = "root", Path = "/root" }; + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + Assert.Equal("root", node.Name); + Assert.Equal("dir", node.Type); + Assert.NotNull(node.Children); + Assert.Empty(node.Children); + Assert.Equal(0, totalFiles); + Assert.Equal(0, totalDirs); + } + + [Fact] + public void FlatFiles_CorrectTotalFilesCount() + { + RpfBinaryFileEntry f1 = new() + { + Name = "a.dat", + NameLower = "a.dat", + Path = "/root/a.dat", + FileSize = 100 + }; + RpfBinaryFileEntry f2 = new() + { + Name = "b.dat", + NameLower = "b.dat", + Path = "/root/b.dat", + FileSize = 200 + }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [f1, f2] + }; + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + Assert.Equal(2, totalFiles); + Assert.Equal(0, totalDirs); + Assert.NotNull(node.Children); + Assert.Equal(2, node.Children.Count); + Assert.All(node.Children, c => Assert.Equal("file", c.Type)); + } + + [Fact] + public void FlatFiles_NodeHasSizeAndType() + { + RpfBinaryFileEntry f = new() + { + Name = "data.dat", + NameLower = "data.dat", + Path = "/root/data.dat", + FileSize = 1024 + }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [f] + }; + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + Json.TreeNode fileNode = node.Children![0]; + Assert.Equal("data.dat", fileNode.Name); + Assert.Equal(1024, fileNode.Size); + Assert.NotNull(fileNode.SizeFormatted); + Assert.Equal("binary", fileNode.FileType); + } + + [Fact] + public void NestedDirectories_CorrectTotalDirsCount() + { + RpfDirectoryEntry inner = new() { Name = "inner", NameLower = "inner", Path = "/root/sub/inner" }; + RpfDirectoryEntry sub = new() + { + Name = "sub", + NameLower = "sub", + Path = "/root/sub", + Directories = [inner] + }; + + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Directories = [sub] + }; + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + Assert.Equal(2, totalDirs); + Assert.NotNull(node.Children); + _ = Assert.Single(node.Children); + Assert.Equal("sub", node.Children[0].Name); + Assert.NotNull(node.Children[0].Children); + Json.TreeNode innerNode = Assert.Single(node.Children[0].Children!); + Assert.Equal("inner", innerNode.Name); + } + + [Fact] + public void DepthZero_NoChildrenCollected() + { + RpfBinaryFileEntry f = new() + { + Name = "data.dat", + NameLower = "data.dat", + Path = "/root/data.dat", + FileSize = 100 + }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [f] + }; + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(depth: 0), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + Assert.NotNull(node.Children); + Assert.Empty(node.Children); + Assert.Equal(0, totalFiles); + } + + [Fact] + public void DepthOne_OnlyFirstLevel() + { + RpfBinaryFileEntry innerFile = new() + { + Name = "deep.dat", + NameLower = "deep.dat", + Path = "/root/sub/deep.dat", + FileSize = 50 + }; + RpfDirectoryEntry sub = new() + { + Name = "sub", + NameLower = "sub", + Path = "/root/sub", + Files = [innerFile] + }; + + RpfBinaryFileEntry topFile = new() + { + Name = "top.dat", + NameLower = "top.dat", + Path = "/root/top.dat", + FileSize = 100 + }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Directories = [sub], + Files = [topFile] + }; + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(depth: 1), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + Assert.Equal(1, totalFiles); // only top.dat counted + Assert.Equal(1, totalDirs); // sub counted as dir + Json.TreeNode subNode = node.Children!.First(c => c.Name == "sub"); + Assert.NotNull(subNode.Children); + Assert.Empty(subNode.Children); // depth limit prevents going deeper + } + + [Fact] + public void FilterWithEmptyDirs_Pruned() + { + RpfDirectoryEntry emptySub = new() + { + Name = "empty", + NameLower = "empty", + Path = "/root/empty" + }; + RpfBinaryFileEntry matchFile = new() + { + Name = "model.ydr", + NameLower = "model.ydr", + Path = "/root/model.ydr", + FileSize = 256 + }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Directories = [emptySub], + Files = [matchFile] + }; + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(filters: ["*.ydr"]), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + // Empty dir pruned, only file remains + Assert.Equal(1, totalFiles); + Assert.Equal(0, totalDirs); + Assert.NotNull(node.Children); + _ = Assert.Single(node.Children); + Assert.Equal("model.ydr", node.Children[0].Name); + } + + [Fact] + public void Cancellation_ThrowsOperationCanceledException() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + + RpfDirectoryEntry dir = new() { Name = "root", NameLower = "root", Path = "/root" }; + int totalFiles = 0, totalDirs = 0; + + _ = Assert.Throws(() => + TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(), 0, ref totalFiles, ref totalDirs, cts.Token)); + } + + [Fact] + public void ResourceFile_VersionPopulated() + { + // Version = (sv << 4) + gv where sv = (sysFlags >> 28) & 0xF, gv = (gfxFlags >> 28) & 0xF + // For version 110 = 0x6E = (6 << 4) + 14: sysFlags = 6 << 28, gfxFlags = 14 << 28 + RpfResourceFileEntry rfe = new() + { + Name = "model.ydr", + NameLower = "model.ydr", + Path = "/root/model.ydr", + FileSize = 512, + SystemFlags = (uint)6 << 28, + GraphicsFlags = (uint)14 << 28 + }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [rfe] + }; + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + Json.TreeNode fileNode = node.Children![0]; + Assert.Equal("resource", fileNode.FileType); + Assert.Equal(110, fileNode.Version); + } + + [Fact] + public void BinaryFile_VersionNull() + { + RpfBinaryFileEntry bfe = new() + { + Name = "data.dat", + NameLower = "data.dat", + Path = "/root/data.dat", + FileSize = 256 + }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [bfe] + }; + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + Json.TreeNode fileNode = node.Children![0]; + Assert.Equal("binary", fileNode.FileType); + Assert.Null(fileNode.Version); + } + + [Fact] + public void DirName_FallsBackToRpfFilePath() + { + RpfDirectoryEntry dir = new() { Name = null!, NameLower = null!, Path = null! }; + RpfFile rpf = new("test.rpf", "/some/path/test.rpf", 0); + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, rpf, MakeOptions(), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + Assert.Equal("test.rpf", node.Name); + Assert.Equal("/some/path/test.rpf", node.Path); + } +} diff --git a/CodeWalker.Cli/Tests/RpfOptionsTests.cs b/CodeWalker.Cli/Tests/RpfOptionsTests.cs index f00bd4357..3a6940a26 100644 --- a/CodeWalker.Cli/Tests/RpfOptionsTests.cs +++ b/CodeWalker.Cli/Tests/RpfOptionsTests.cs @@ -72,4 +72,24 @@ public void Parse_Aliases() Assert.True(rpfOpts.Verbose); Assert.Equal(2, rpfOpts.Threads); } + + [Fact] + public void AddTo_IncludesThreadsByDefault() + { + RootCommand root = []; + RpfCommandOptions opts = new(); + opts.AddTo(root); + ParseResult pr = root.Parse("--rpf /tmp/test.rpf --exe /tmp/testdir --threads 2"); + Assert.Empty(pr.Errors); + } + + [Fact] + public void AddTo_ExcludesThreads_WhenFlagIsFalse() + { + RootCommand root = []; + RpfCommandOptions opts = new(); + opts.AddTo(root, includeThreads: false); + ParseResult pr = root.Parse("--rpf /tmp/test.rpf --exe /tmp/testdir --threads 2"); + Assert.NotEmpty(pr.Errors); + } } From 54903b74290d7aa4875b173a376df34f2dd46de2 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:07:55 +0200 Subject: [PATCH 27/45] refactor(cli): rewrite the search matcher and add --dir The matcher tried to guess whether a pattern was a glob and silently switched behaviour on the answer, so '*' in a file name and '*' as a wildcard could not be told apart. Matching is a plain case-insensitive substring test now, and the JSON says so. --dir searches every archive below a directory, which is what looking for a file across an installation actually needs. search marks --rpf optional and validates that exactly one of the two is given. The result carries rpfFiles, the archives that were searched, and each match names the archive it came from. nameHash and shortNameHash are gone from the match records. They were the Jenkins hashes of the entry name, which the hash command already prints, and nothing here consumed them. --- CodeWalker.Cli/Handlers/SearchHandler.cs | 422 +++-- CodeWalker.Cli/Json/SearchResult.cs | 12 +- CodeWalker.Cli/RpfOptions.cs | 2 +- .../Tests/Handlers/SearchHandlerTests.cs | 1430 ++++++++++++++++- 4 files changed, 1654 insertions(+), 212 deletions(-) diff --git a/CodeWalker.Cli/Handlers/SearchHandler.cs b/CodeWalker.Cli/Handlers/SearchHandler.cs index 52e2e328e..e257d8480 100644 --- a/CodeWalker.Cli/Handlers/SearchHandler.cs +++ b/CodeWalker.Cli/Handlers/SearchHandler.cs @@ -2,10 +2,8 @@ using System.Collections.Generic; using System.CommandLine; using System.IO; -using System.Linq; using System.Text.Json; using System.Threading; -using System.Threading.Tasks; using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; @@ -16,39 +14,52 @@ internal static class SearchHandler { public static Command CreateCommand(CancellationToken cancellationToken = default) { - RpfCommandOptions rpfOpts = new(); + RpfCommandOptions rpfOpts = new() + { + Rpf = { Required = false } + }; + + Option dirOpt = new("--dir", "-D") + { + Description = "Directory to search — discovers all .rpf files recursively", + }; + Argument patternArg = new("pattern") { - Description = "Search pattern: glob, substring, or hash (0x hex or decimal)", + Description = "Substring to search for in file paths", }; - Command command = new("search", "Search for files by name, path, or hash in an RPF archive") + Command command = new("search", "Search for files by name or path in an RPF archive") { patternArg, }; - rpfOpts.AddTo(command); + rpfOpts.AddTo(command, includeThreads: false); + command.Add(dirOpt); command.Aliases.Add("s"); + command.Validators.Add(result => + { + bool hasRpf = result.GetValue(rpfOpts.Rpf) != null; + bool hasDir = result.GetValue(dirOpt) != null; + if (hasRpf == hasDir) + result.AddError("Specify exactly one of --rpf or --dir."); + }); + command.SetAction(parseResult => - Execute(rpfOpts.Parse(parseResult), parseResult.GetRequiredValue(patternArg), cancellationToken) + Execute( + rpfOpts.Parse(parseResult), + parseResult.GetRequiredValue(patternArg), + parseResult.GetValue(dirOpt)?.FullName, + cancellationToken) ); return command; } - public static int Execute(RpfOptions options, string pattern, CancellationToken cancellationToken = default) + public static int Execute(RpfOptions options, string pattern, string? dirPath = null, CancellationToken cancellationToken = default) { - Json.SearchResult ErrorResult(string[] errorMessages) => - new() - { - Success = false, - RpfFile = options.RpfPath, - Pattern = pattern, - PatternType = "unknown", - MatchCount = 0, - Matches = [], - ErrorMessages = errorMessages, - }; + if (dirPath != null) + return ExecuteDirectory(options, pattern, dirPath, cancellationToken); string? initError = RpfService.ValidateAndLoadKeys( options.RpfPath, @@ -58,7 +69,11 @@ Json.SearchResult ErrorResult(string[] errorMessages) => ); if (initError != null) { - return RpfService.ReportError(initError, options.Json, ErrorResult([])); + return RpfService.ReportError( + initError, + options.Json, + ErrorResult([], options, pattern) + ); } List scanErrors = []; @@ -72,176 +87,261 @@ Json.SearchResult ErrorResult(string[] errorMessages) => ); if (!options.Json) - { Console.Error.WriteLine(); - } - // Collect all entries (including directories) recursively - List allEntries = []; - CollectAllEntries(rpf, options.Recursive, allEntries); + Json.SearchResult result = CollectSearch(rpf, scanErrors, options, pattern, cancellationToken: cancellationToken); - // Detect pattern type - string patternType; - Func matcher; + if (options.Json) + PrintJsonSearch(result); + else + PrintSearch(result, options); - if (pattern.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + return scanErrors.Count > 0 ? 1 : 0; + } + catch (OperationCanceledException) + { + // Gracefully handle cancellation without printing an error message + throw; + } + catch (Exception ex) + { + return RpfService.ReportError( + ex.Message, + options.Json, + ErrorResult([.. scanErrors], options, pattern), + options.Verbose ? ex.StackTrace : null + ); + } + } + + internal static int ExecuteDirectory(RpfOptions options, string pattern, string dirPath, CancellationToken cancellationToken) + { + if (!Directory.Exists(dirPath)) + { + return RpfService.ReportError( + $"Directory not found: {dirPath}", + options.Json, + ErrorResult([], options, pattern) + ); + } + + string[] rpfPaths = Directory.GetFiles(dirPath, "*.rpf", SearchOption.AllDirectories); + Array.Sort(rpfPaths, StringComparer.OrdinalIgnoreCase); + + if (rpfPaths.Length == 0) + { + return RpfService.ReportError( + $"No .rpf files found in: {dirPath}", + options.Json, + ErrorResult([], options, pattern) + ); + } + + string? initError = RpfService.ValidateExeAndLoadKeys( + options.ExePath, + options.Gen9, + options.Json + ); + if (initError != null) + { + return RpfService.ReportError( + initError, + options.Json, + ErrorResult([], options, pattern) + ); + } + + List allScanErrors = []; + List allMatches = []; + List rpfFiles = []; + + foreach (string rpfPath in rpfPaths) + { + cancellationToken.ThrowIfCancellationRequested(); + + List scanErrors = []; + try { - // Hex hash - patternType = "hash_hex"; - if ( - !uint.TryParse( - pattern[2..], - System.Globalization.NumberStyles.HexNumber, - null, - out uint hash - ) - ) - { - return RpfService.ReportError( - $"Invalid hex hash: {pattern}", - options.Json, - ErrorResult([.. scanErrors]) - ); - } - matcher = entry => entry.NameHash == hash || entry.ShortNameHash == hash; + RpfFile rpf = RpfService.OpenRpf( + rpfPath, + options.Verbose, + options.Json, + scanErrors + ); + + Json.SearchResult partialResult = CollectSearch(rpf, scanErrors, options, pattern, archive: rpfPath, cancellationToken: cancellationToken); + rpfFiles.Add(rpfPath); + + allMatches.AddRange(partialResult.Matches); + allScanErrors.AddRange(scanErrors); } - else if ( - uint.TryParse(pattern, out uint decHash) - && pattern.Length >= 5 - && !HasGlobChars(pattern) - ) + catch (OperationCanceledException) { - // Decimal hash (require 5+ digits to avoid matching short filenames) - patternType = "hash_decimal"; - matcher = entry => entry.NameHash == decHash || entry.ShortNameHash == decHash; + throw; } - else if (HasGlobChars(pattern)) + catch (Exception ex) { - // Glob pattern — reuse Filter.Matches - patternType = "glob"; - string[] filters = Filter.Normalize([pattern]); - matcher = entry => entry.Path != null && Filter.Matches(entry.Path, filters); + allScanErrors.Add($"{rpfPath}: {ex.Message}"); } - else + } + + if (!options.Json) + Console.Error.WriteLine(); + + Json.SearchResult result = new() + { + Success = allScanErrors.Count == 0, + RpfFile = dirPath, + RpfFiles = rpfFiles, + Pattern = pattern, + PatternType = "substring", + MatchCount = allMatches.Count, + Matches = allMatches, + ErrorMessages = [.. allScanErrors], + }; + + if (options.Json) + PrintJsonSearch(result); + else + PrintSearch(result, options); + + return allScanErrors.Count > 0 ? 1 : 0; + } + + internal static Json.SearchResult ErrorResult(string[] errorMessages, RpfOptions options, string pattern) => + new() + { + Success = false, + RpfFile = options.RpfPath, + RpfFiles = [], + Pattern = pattern, + PatternType = "substring", + MatchCount = 0, + Matches = [], + ErrorMessages = errorMessages, + }; + + internal static Json.SearchResult CollectSearch( + RpfFile rpf, + List scanErrors, + RpfOptions options, + string pattern, + string? archive = null, + CancellationToken cancellationToken = default) + { + string archivePath = archive ?? options.RpfPath; + string normalizedPattern = pattern.Replace('\\', '/'); + + List allEntries = []; + CollectAllEntries(rpf, options.Recursive, allEntries); + + List matches = []; + + foreach (RpfEntry entry in allEntries) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!Filter.Matches(entry.Path ?? "", options.Filters)) + continue; + + if (entry.Path?.Replace('\\', '/').Contains(normalizedPattern, StringComparison.OrdinalIgnoreCase) != true) + continue; + + long size = 0; + string type = "directory"; + string ext = ""; + + if (entry is RpfFileEntry fileEntry) { - // Substring match - patternType = "substring"; - string normalizedPattern = pattern.Replace('\\', '/'); - matcher = entry => - entry.Path?.Replace('\\', '/').Contains(normalizedPattern, StringComparison.OrdinalIgnoreCase) == true; + size = fileEntry.GetFileSize(); + type = RpfService.GetFileType(fileEntry); + ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); } - // Match in parallel - Json.SearchMatch?[] results = new Json.SearchMatch?[allEntries.Count]; - - _ = Parallel.For( - 0, - allEntries.Count, - new ParallelOptions { MaxDegreeOfParallelism = options.Threads, CancellationToken = cancellationToken }, - i => - { - RpfEntry entry = allEntries[i]; - if (!matcher(entry)) - return; - - long size = 0; - string type = "directory"; - string ext = ""; - - if (entry is RpfFileEntry fileEntry) - { - size = fileEntry.GetFileSize(); - type = RpfService.GetFileType(fileEntry); - ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); - } - - results[i] = new Json.SearchMatch - { - Path = entry.Path ?? entry.Name ?? "", - Name = entry.Name ?? "", - Size = size, - Type = type, - Extension = ext, - NameHash = entry.NameHash, - ShortNameHash = entry.ShortNameHash, - }; - } - ); + matches.Add(new Json.SearchMatch + { + Archive = archivePath, + Path = entry.Path ?? entry.Name ?? "", + Name = entry.Name ?? "", + Size = size, + Type = type, + Extension = ext, + }); + } + + return new Json.SearchResult + { + Success = scanErrors.Count == 0, + RpfFile = archivePath, + RpfFiles = [archivePath], + Pattern = pattern, + PatternType = "substring", + MatchCount = matches.Count, + Matches = matches, + ErrorMessages = [.. scanErrors], + }; + } + + internal static void PrintJsonSearch(Json.SearchResult result) => + Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); - // Collect non-null results - List matches = results.OfType().ToList(); + internal static void PrintSearch(Json.SearchResult result, RpfOptions options) + { + bool multiArchive = result.RpfFiles.Count > 1; + string? lastArchive = null; - Json.SearchResult result = new() + foreach (Json.SearchMatch match in result.Matches) + { + if (multiArchive && match.Archive != lastArchive) { - Success = scanErrors.Count == 0, - RpfFile = options.RpfPath, - Pattern = pattern, - PatternType = patternType, - MatchCount = matches.Count, - Matches = matches, - ErrorMessages = [.. scanErrors], - }; + if (lastArchive != null) + Console.Error.WriteLine(); + Console.Error.WriteLine($"== {RelativePath(result.RpfFile, match.Archive)} =="); + lastArchive = match.Archive; + } - if (options.Json) + if (options.Verbose) { - Console.WriteLine( - JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) - ); + string sizeStr = options + .SizeFormat.ToFormattedString(match.Size) + .PadLeft(12); + Console.WriteLine($"{sizeStr} {match.Path}"); } else { - foreach (Json.SearchMatch match in matches) - { - if (options.Verbose) - { - string sizeStr = options - .SizeFormat.ToFormattedString(match.Size) - .PadLeft(12); - Console.WriteLine($"{sizeStr} {match.Path}"); - } - else - { - Console.WriteLine(match.Path); - } - } - - Console.Error.WriteLine(); - Console.Error.WriteLine( - $"Found {matches.Count} matches for '{pattern}' ({patternType})" - ); + Console.WriteLine(match.Path); } - - return scanErrors.Count > 0 ? 1 : 0; - } - catch (OperationCanceledException) { throw; } - catch (Exception ex) - { - return RpfService.ReportError( - ex.Message, - options.Json, - ErrorResult([.. scanErrors]), - options.Verbose ? ex.StackTrace : null - ); } + + string matchWord = result.MatchCount == 1 ? "match" : "matches"; + Console.Error.WriteLine(); + Console.Error.WriteLine( + multiArchive + ? $"Found {result.MatchCount} {matchWord} across {result.RpfFiles.Count} archive(s) for '{result.Pattern}' ({result.PatternType})" + : $"Found {result.MatchCount} {matchWord} for '{result.Pattern}' ({result.PatternType})" + ); } - internal static bool HasGlobChars(string s) => - s.Contains('*', StringComparison.Ordinal) || - s.Contains('?', StringComparison.Ordinal); + internal static string RelativePath(string basePath, string fullPath) + { + // Normalize separators and ensure trailing separator on base + string normalizedBase = basePath.Replace('\\', '/').TrimEnd('/') + "/"; + string normalizedFull = fullPath.Replace('\\', '/'); + + return normalizedFull.StartsWith(normalizedBase, StringComparison.OrdinalIgnoreCase) + ? normalizedFull[normalizedBase.Length..] + : Path.GetFileName(fullPath); + } - private static void CollectAllEntries(RpfFile rpf, bool recursive, List entries) + internal static void CollectAllEntries(RpfFile rpf, bool recursive, List entries) { if (rpf.AllEntries != null) - { entries.AddRange(rpf.AllEntries); - } - if (recursive && rpf.Children != null) - { - foreach (RpfFile child in rpf.Children) - { - CollectAllEntries(child, recursive, entries); - } - } + if (!recursive || rpf.Children == null) + return; + + foreach (RpfFile child in rpf.Children) + CollectAllEntries(child, recursive, entries); } } diff --git a/CodeWalker.Cli/Json/SearchResult.cs b/CodeWalker.Cli/Json/SearchResult.cs index 252639028..2dfc3ef63 100644 --- a/CodeWalker.Cli/Json/SearchResult.cs +++ b/CodeWalker.Cli/Json/SearchResult.cs @@ -7,6 +7,9 @@ namespace CodeWalker.Cli.Json; [ExcludeFromCodeCoverage] internal sealed record SearchMatch { + [JsonPropertyName("archive")] + public required string Archive { get; init; } + [JsonPropertyName("path")] public required string Path { get; init; } @@ -21,12 +24,6 @@ internal sealed record SearchMatch [JsonPropertyName("extension")] public required string Extension { get; init; } - - [JsonPropertyName("nameHash")] - public required uint NameHash { get; init; } - - [JsonPropertyName("shortNameHash")] - public required uint ShortNameHash { get; init; } } [ExcludeFromCodeCoverage] @@ -35,6 +32,9 @@ internal sealed record SearchResult : BaseResult [JsonPropertyName("rpfFile")] public required string RpfFile { get; init; } + [JsonPropertyName("rpfFiles")] + public required IReadOnlyList RpfFiles { get; init; } + [JsonPropertyName("pattern")] public required string Pattern { get; init; } diff --git a/CodeWalker.Cli/RpfOptions.cs b/CodeWalker.Cli/RpfOptions.cs index 4776892cc..44a59d948 100644 --- a/CodeWalker.Cli/RpfOptions.cs +++ b/CodeWalker.Cli/RpfOptions.cs @@ -65,7 +65,7 @@ public RpfOptions Parse(ParseResult parseResult) CommonOptions common = this._commonOpts.Parse(parseResult); return new RpfOptions { - RpfPath = parseResult.GetRequiredValue(this.Rpf).FullName, + RpfPath = parseResult.GetValue(this.Rpf)?.FullName ?? "", ExePath = common.ExePath, Gen9 = parseResult.GetValue(this.Gen9), Filters = Helpers.Filter.Normalize(parseResult.GetValue(this.Filter)), diff --git a/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs index f93dcbcd4..cb2004ca8 100644 --- a/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs @@ -1,79 +1,1339 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Threading; using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; using Xunit; namespace CodeWalker.Cli.Tests.Handlers; -public sealed class SearchHandlerTests +// ── ErrorResult ───────────────────────────────────────────────────── + +public sealed class SearchErrorResultTests +{ + private static RpfOptions MakeOptions(string rpfPath = "/test.rpf") => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, + }; + + [Fact] + public void ErrorResult_SetsSuccessFalse() + { + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(), "*.ydr"); + Assert.False(result.Success); + } + + [Fact] + public void ErrorResult_PreservesRpfFile() + { + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions("/my/test.rpf"), "test"); + Assert.Equal("/my/test.rpf", result.RpfFile); + } + + [Fact] + public void ErrorResult_PreservesPattern() + { + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(), "adder"); + Assert.Equal("adder", result.Pattern); + } + + [Fact] + public void ErrorResult_SetsPatternTypeSubstring() + { + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(), "*.ydr"); + Assert.Equal("substring", result.PatternType); + } + + [Fact] + public void ErrorResult_SetsMatchCountZero() + { + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(), "*.ydr"); + Assert.Equal(0, result.MatchCount); + } + + [Fact] + public void ErrorResult_SetsEmptyMatches() + { + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(), "*.ydr"); + Assert.Empty(result.Matches); + } + + [Fact] + public void ErrorResult_PreservesErrorMessages() + { + string[] msgs = ["err1", "err2"]; + Json.SearchResult result = SearchHandler.ErrorResult(msgs, MakeOptions(), "*.ydr"); + Assert.Equal(msgs, result.ErrorMessages); + } +} + +// ── CollectSearch ─────────────────────────────────────────────────── + +public sealed class SearchCollectSearchTests +{ + private static RpfOptions MakeOptions( + string rpfPath = "/test.rpf", + bool recursive = false, + bool verbose = false, + string[]? filters = null) => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = filters ?? [], + Verbose = verbose, + Json = false, + Recursive = recursive, + Threads = 1, + SizeFormat = SizeFormat.IEC, + }; + + private static RpfFile MakeRpf() => + new("test.rpf", "test.rpf", 0); + + private static RpfBinaryFileEntry MakeBinary(string name, string path, uint fileSize = 1024) => + new() + { + Name = name, + NameLower = name.ToLowerInvariant(), + Path = path, + FileSize = fileSize, + FileUncompressedSize = fileSize, + }; + + private static RpfResourceFileEntry MakeResource(string name, string path, uint fileSize = 2048) => + new() + { + Name = name, + NameLower = name.ToLowerInvariant(), + Path = path, + FileSize = fileSize, + }; + + [Fact] + public void CollectSearch_EmptyEntries_ReturnsZeroMatches() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = []; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "adder", cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(0, result.MatchCount); + Assert.Empty(result.Matches); + } + + [Fact] + public void CollectSearch_NullEntries_ReturnsZeroMatches() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = null; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "adder", cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(0, result.MatchCount); + } + + [Fact] + public void CollectSearch_SubstringMatch_FindsEntries() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("adder.ydr", "vehicles/adder.ydr"), + MakeBinary("zentorno.ydr", "vehicles/zentorno.ydr"), + MakeBinary("adder.ytd", "vehicles/adder.ytd"), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "adder", cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(2, result.MatchCount); + Assert.Equal("substring", result.PatternType); + } + + [Fact] + public void CollectSearch_ExtensionSubstring_FindsEntries() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("adder.ydr", "vehicles/adder.ydr"), + MakeBinary("adder.ytd", "vehicles/adder.ytd"), + MakeBinary("zentorno.ydr", "vehicles/zentorno.ydr"), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), ".ydr", cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(2, result.MatchCount); + Assert.Equal("substring", result.PatternType); + } + + [Fact] + public void CollectSearch_MatchPopulatesAllFields() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("adder.ydr", "vehicles/adder.ydr", fileSize: 4096), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "adder", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, result.MatchCount); + Json.SearchMatch match = result.Matches[0]; + Assert.Equal("vehicles/adder.ydr", match.Path); + Assert.Equal("adder.ydr", match.Name); + Assert.Equal(4096, match.Size); + Assert.Equal("binary", match.Type); + Assert.Equal(".ydr", match.Extension); + } + + [Fact] + public void CollectSearch_ResourceEntry_SetsTypeResource() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeResource("adder.ydr", "vehicles/adder.ydr"), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "adder", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, result.MatchCount); + Assert.Equal("resource", result.Matches[0].Type); + } + + [Fact] + public void CollectSearch_DirectoryEntry_SetsTypeDirectory() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + new RpfDirectoryEntry + { + Name = "vehicles", + NameLower = "vehicles", + Path = "vehicles", + }, + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "vehicles", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, result.MatchCount); + Assert.Equal("directory", result.Matches[0].Type); + Assert.Equal(0, result.Matches[0].Size); + Assert.Equal("", result.Matches[0].Extension); + } + + [Fact] + public void CollectSearch_NoMatch_ReturnsEmpty() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("adder.ydr", "vehicles/adder.ydr"), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "weapons", cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(0, result.MatchCount); + Assert.Empty(result.Matches); + } + + [Fact] + public void CollectSearch_ScanErrors_SetsSuccessFalse() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("adder.ydr", "vehicles/adder.ydr"), + ]; + List scanErrors = ["scan error 1"]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, scanErrors, MakeOptions(), "adder", cancellationToken: TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Equal(1, result.MatchCount); + Assert.Contains("scan error 1", result.ErrorMessages); + } + + [Fact] + public void CollectSearch_WithFilter_NarrowsResults() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("adder.ydr", "vehicles/adder.ydr"), + MakeBinary("adder.ytd", "vehicles/adder.ytd"), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(filters: Filter.Normalize(["*.ydr"])), "adder", cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(1, result.MatchCount); + Assert.Equal("adder.ydr", result.Matches[0].Name); + } + + [Fact] + public void CollectSearch_WithFilter_EmptyFilters_MatchesAll() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("adder.ydr", "vehicles/adder.ydr"), + MakeBinary("adder.ytd", "vehicles/adder.ytd"), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "adder", cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(2, result.MatchCount); + } + + [Fact] + public void CollectSearch_BackslashPattern_NormalizesAndMatches() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("adder.ydr", "vehicles/adder.ydr"), + MakeBinary("zentorno.ydr", "vehicles/zentorno.ydr"), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "vehicles\\adder", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, result.MatchCount); + Assert.Equal("vehicles/adder.ydr", result.Matches[0].Path); + } + + [Fact] + public void CollectSearch_CaseInsensitive_MatchesUppercasePattern() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("adder.ydr", "vehicles/adder.ydr"), + MakeBinary("zentorno.ydr", "vehicles/zentorno.ydr"), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "ADDER", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, result.MatchCount); + Assert.Equal("adder.ydr", result.Matches[0].Name); + } + + [Fact] + public void CollectSearch_NullPathEntry_IsSkipped() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + new RpfBinaryFileEntry + { + Name = "adder.ydr", + NameLower = "adder.ydr", + Path = null, + FileSize = 1024, + FileUncompressedSize = 1024, + }, + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "adder", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(0, result.MatchCount); + Assert.Empty(result.Matches); + } + + [Fact] + public void CollectSearch_SetsRpfFileAndPattern() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = []; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions("/my/archive.rpf"), "test", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("/my/archive.rpf", result.RpfFile); + Assert.Equal("test", result.Pattern); + } +} + +// ── CollectAllEntries ─────────────────────────────────────────────── + +public sealed class SearchCollectAllEntriesTests +{ + private static RpfFile MakeRpf() => + new("test.rpf", "test.rpf", 0); + + private static RpfBinaryFileEntry MakeBinary(string name, string path) => + new() + { + Name = name, + NameLower = name.ToLowerInvariant(), + Path = path, + FileSize = 1024, + FileUncompressedSize = 1024, + }; + + [Fact] + public void CollectAllEntries_NullEntries_CollectsNothing() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = null; + List entries = []; + + SearchHandler.CollectAllEntries(rpf, recursive: false, entries); + + Assert.Empty(entries); + } + + [Fact] + public void CollectAllEntries_FlatEntries_CollectsAll() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = [MakeBinary("a.ydr", "a.ydr"), MakeBinary("b.ydr", "b.ydr")]; + List entries = []; + + SearchHandler.CollectAllEntries(rpf, recursive: false, entries); + + Assert.Equal(2, entries.Count); + } + + [Fact] + public void CollectAllEntries_NotRecursive_SkipsChildren() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = [MakeBinary("a.ydr", "a.ydr")]; + + RpfFile child = new("child.rpf", "child.rpf", 0) + { + AllEntries = [MakeBinary("b.ydr", "child.rpf/b.ydr")], + }; + rpf.Children = [child]; + + List entries = []; + SearchHandler.CollectAllEntries(rpf, recursive: false, entries); + + _ = Assert.Single(entries); + Assert.Equal("a.ydr", entries[0].Name); + } + + [Fact] + public void CollectAllEntries_Recursive_IncludesChildren() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = [MakeBinary("a.ydr", "a.ydr")]; + + RpfFile child = new("child.rpf", "child.rpf", 0) + { + AllEntries = [MakeBinary("b.ydr", "child.rpf/b.ydr")], + }; + rpf.Children = [child]; + + List entries = []; + SearchHandler.CollectAllEntries(rpf, recursive: true, entries); + + Assert.Equal(2, entries.Count); + } + + [Fact] + public void CollectAllEntries_Recursive_NestedChildren() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = [MakeBinary("a.ydr", "a.ydr")]; + + RpfFile grandchild = new("grandchild.rpf", "grandchild.rpf", 0) + { + AllEntries = [MakeBinary("c.ydr", "grandchild.rpf/c.ydr")], + }; + + RpfFile child = new("child.rpf", "child.rpf", 0) + { + AllEntries = [MakeBinary("b.ydr", "child.rpf/b.ydr")], + Children = [grandchild], + }; + rpf.Children = [child]; + + List entries = []; + SearchHandler.CollectAllEntries(rpf, recursive: true, entries); + + Assert.Equal(3, entries.Count); + } + + [Fact] + public void CollectAllEntries_NullChildren_DoesNotThrow() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = [MakeBinary("a.ydr", "a.ydr")]; + rpf.Children = null; + + List entries = []; + SearchHandler.CollectAllEntries(rpf, recursive: true, entries); + + _ = Assert.Single(entries); + } +} + +// ── Cancellation ──────────────────────────────────────────────────── + +public sealed class SearchCancellationTests +{ + private static RpfFile MakeRpf() => + new("test.rpf", "test.rpf", 0); + + private static RpfBinaryFileEntry MakeBinary(string name, string path) => + new() + { + Name = name, + NameLower = name.ToLowerInvariant(), + Path = path, + FileSize = 1024, + FileUncompressedSize = 1024, + }; + + private static RpfOptions MakeOptions() => + new() + { + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, + }; + + [Fact] + public void CollectSearch_Cancelled_ThrowsOperationCanceledException() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("a.ydr", "a.ydr"), + MakeBinary("b.ydr", "b.ydr"), + ]; + + using CancellationTokenSource cts = new(); + cts.Cancel(); + + _ = Assert.Throws( + () => SearchHandler.CollectSearch(rpf, [], MakeOptions(), "*", cancellationToken: cts.Token) + ); + } +} + +// ── PrintSearch / PrintJsonSearch ──────────────────────────────────── + +[Collection("ConsoleOutput")] +public sealed class SearchPrintTests +{ + private static readonly char[] SplitChars = ['\r', '\n']; + + private static RpfOptions MakeOptions(bool verbose = false, SizeFormat sizeFormat = SizeFormat.IEC) => + new() + { + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = verbose, + Json = false, + Recursive = false, + Threads = 1, + SizeFormat = sizeFormat, + }; + + private static Json.SearchResult MakeResult( + List? matches = null, + string pattern = "*.ydr", + string patternType = "glob", + int? matchCount = null, + IReadOnlyList? rpfFiles = null) => + new() + { + Success = true, + RpfFile = "/test.rpf", + RpfFiles = rpfFiles ?? ["/test.rpf"], + Pattern = pattern, + PatternType = patternType, + MatchCount = matchCount ?? matches?.Count ?? 0, + Matches = matches ?? [], + ErrorMessages = [], + }; + + private static Json.SearchMatch MakeMatch( + string path = "vehicles/adder.ydr", + string name = "adder.ydr", + long size = 4096, + string type = "binary", + string extension = ".ydr", + string archive = "/test.rpf") => + new() + { + Archive = archive, + Path = path, + Name = name, + Size = size, + Type = type, + Extension = extension, + }; + + // ── PrintSearch (text) ────────────────────────────────────────── + + [Fact] + public void PrintSearch_NonVerbose_PrintsPathsOnly() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = MakeResult([MakeMatch(), MakeMatch("weapons/pistol.ydr", "pistol.ydr")]); + SearchHandler.PrintSearch(result, MakeOptions(verbose: false)); + + string output = stdout.ToString(); + string[] lines = output.Split(SplitChars, StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(2, lines.Length); + Assert.Equal("vehicles/adder.ydr", lines[0]); + Assert.Equal("weapons/pistol.ydr", lines[1]); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintSearch_Verbose_PrintsSizeAndPath() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = MakeResult([MakeMatch(size: 1024)]); + SearchHandler.PrintSearch(result, MakeOptions(verbose: true)); + + string output = stdout.ToString(); + Assert.Contains("1 KiB", output); + Assert.Contains("vehicles/adder.ydr", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintSearch_PrintsSummaryToStderr() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = MakeResult( + [MakeMatch()], + pattern: "adder", + patternType: "substring"); + SearchHandler.PrintSearch(result, MakeOptions()); + + string errOutput = stderr.ToString(); + Assert.Contains("Found 1 match for", errOutput); + Assert.Contains("'adder'", errOutput); + Assert.Contains("(substring)", errOutput); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintSearch_EmptyResults_PrintsSummaryOnly() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = MakeResult(pattern: "nothing", patternType: "substring"); + SearchHandler.PrintSearch(result, MakeOptions()); + + Assert.Equal("", stdout.ToString()); + Assert.Contains("Found 0 matches", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintSearch_Verbose_SingleArchive_NoArchiveHeaders() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = MakeResult([MakeMatch(size: 2048)]); + SearchHandler.PrintSearch(result, MakeOptions(verbose: true)); + + string errOutput = stderr.ToString(); + Assert.DoesNotContain("==", errOutput); + + string stdoutOutput = stdout.ToString(); + Assert.Contains("2 KiB", stdoutOutput); + Assert.Contains("vehicles/adder.ydr", stdoutOutput); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintSearch_Verbose_SIFormat_PrintsSIUnits() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = MakeResult([MakeMatch(size: 1000)]); + SearchHandler.PrintSearch(result, MakeOptions(verbose: true, sizeFormat: SizeFormat.SI)); + + string output = stdout.ToString(); + Assert.Contains("1 KB", output); + Assert.DoesNotContain("KiB", output); + Assert.Contains("vehicles/adder.ydr", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + // ── PrintJsonSearch ───────────────────────────────────────────── + + [Fact] + public void PrintJsonSearch_OutputsValidJson() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + Json.SearchResult result = MakeResult( + [MakeMatch()], + pattern: "adder", + patternType: "substring"); + SearchHandler.PrintJsonSearch(result); + + string output = stdout.ToString(); + Assert.Contains("\"success\": true", output); + Assert.Contains("\"pattern\": \"adder\"", output); + Assert.Contains("\"patternType\": \"substring\"", output); + Assert.Contains("\"matchCount\": 1", output); + Assert.Contains("\"path\": \"vehicles/adder.ydr\"", output); + } + finally + { + Console.SetOut(origOut); + } + } + + [Fact] + public void PrintJsonSearch_EmptyMatches_OutputsEmptyArray() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + Json.SearchResult result = MakeResult(); + SearchHandler.PrintJsonSearch(result); + + string output = stdout.ToString(); + Assert.Contains("\"matches\": []", output); + Assert.Contains("\"matchCount\": 0", output); + } + finally + { + Console.SetOut(origOut); + } + } +} + +// ── Execute (validation failures) ─────────────────────────────────── + +[Collection("ConsoleOutput")] +public sealed class SearchHandlerExecuteTests +{ + private static RpfOptions MakeOptions(string rpfPath, bool json) => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, + }; + + [Fact] + public void Execute_MissingRpf_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), "*.ydr", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_MissingRpf_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "adder", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("RPF file not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "test*", cancellationToken: TestContext.Current.CancellationToken); + + string output = stdout.ToString(); + Assert.Contains("\"rpfFile\":", output); + Assert.Contains("\"pattern\":", output); + Assert.Contains("\"matchCount\": 0", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void Execute_WithDirPath_DelegatesToExecuteDirectory() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = SearchHandler.Execute( + MakeOptions("/unused.rpf", json: false), + "*.ydr", + dirPath: "/nonexistent_dir_xyz_12345", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("Directory not found", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_WithDirPath_Json_DelegatesToExecuteDirectory() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = SearchHandler.Execute( + MakeOptions("/unused.rpf", json: true), + "adder", + dirPath: "/nonexistent_dir_xyz_12345", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("Directory not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } +} + +// ── CollectSearch Archive field ───────────────────────────────────── + +public sealed class SearchCollectSearchArchiveTests { - // ── HasGlobChars ────────────────────────────────────────────────── + private static RpfOptions MakeOptions(string rpfPath = "/test.rpf") => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, + }; + + private static RpfFile MakeRpf() => + new("test.rpf", "test.rpf", 0); + + private static RpfBinaryFileEntry MakeBinary(string name, string path, uint fileSize = 1024) => + new() + { + Name = name, + NameLower = name.ToLowerInvariant(), + Path = path, + FileSize = fileSize, + FileUncompressedSize = fileSize, + }; + + [Fact] + public void CollectSearch_DefaultArchive_UsesRpfPath() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = [MakeBinary("a.ydr", "a.ydr")]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions("/my/archive.rpf"), "a", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("/my/archive.rpf", result.Matches[0].Archive); + Assert.Equal("/my/archive.rpf", result.RpfFile); + _ = Assert.Single(result.RpfFiles); + Assert.Equal("/my/archive.rpf", result.RpfFiles[0]); + } [Fact] - public void HasGlobChars_WithAsterisk_ReturnsTrue() => - Assert.True(SearchHandler.HasGlobChars("*.ydr")); + public void CollectSearch_ExplicitArchive_MultipleMatches_AllHaveArchiveField() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("a.ydr", "a.ydr"), + MakeBinary("b.ydr", "b.ydr"), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), ".ydr", archive: "/dir/test.rpf", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(2, result.MatchCount); + Assert.All(result.Matches, m => Assert.Equal("/dir/test.rpf", m.Archive)); + } [Fact] - public void HasGlobChars_WithQuestionMark_ReturnsTrue() => - Assert.True(SearchHandler.HasGlobChars("file?.txt")); + public void CollectSearch_ExplicitArchive_SetsArchiveField() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = [MakeBinary("a.ydr", "a.ydr")]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "a", archive: "/dir/custom.rpf", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("/dir/custom.rpf", result.Matches[0].Archive); + Assert.Equal("/dir/custom.rpf", result.RpfFile); + _ = Assert.Single(result.RpfFiles); + Assert.Equal("/dir/custom.rpf", result.RpfFiles[0]); + } +} + +// ── ErrorResult RpfFiles ──────────────────────────────────────────── + +public sealed class SearchErrorResultRpfFilesTests +{ + private static RpfOptions MakeOptions() => + new() + { + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, + }; [Fact] - public void HasGlobChars_WithBoth_ReturnsTrue() => - Assert.True(SearchHandler.HasGlobChars("**/dir?.txt")); + public void ErrorResult_SetsEmptyRpfFiles() + { + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(), "*.ydr"); + Assert.Empty(result.RpfFiles); + } +} + +// ── PrintSearch multi-archive ─────────────────────────────────────── + +[Collection("ConsoleOutput")] +public sealed class SearchPrintMultiArchiveTests +{ + private static readonly char[] SplitChars = ['\r', '\n']; + + private static RpfOptions MakeOptions(bool verbose = false) => + new() + { + RpfPath = "/dir", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = verbose, + Json = false, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, + }; + + private static Json.SearchMatch MakeMatch(string archive, string path, string name) => + new() + { + Archive = archive, + Path = path, + Name = name, + Size = 1024, + Type = "binary", + Extension = ".ydr", + }; [Fact] - public void HasGlobChars_PlainString_ReturnsFalse() => - Assert.False(SearchHandler.HasGlobChars("vehicles")); + public void PrintSearch_MultiRpf_GroupsByArchive() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = new() + { + Success = true, + RpfFile = "/dir", + RpfFiles = ["/dir/a.rpf", "/dir/b.rpf"], + Pattern = "*.ydr", + PatternType = "glob", + MatchCount = 2, + Matches = + [ + MakeMatch("/dir/a.rpf", "vehicles/adder.ydr", "adder.ydr"), + MakeMatch("/dir/b.rpf", "vehicles/zentorno.ydr", "zentorno.ydr"), + ], + ErrorMessages = [], + }; + + SearchHandler.PrintSearch(result, MakeOptions()); + + string errOutput = stderr.ToString(); + Assert.Contains("== a.rpf ==", errOutput); + Assert.Contains("== b.rpf ==", errOutput); + + string[] stdoutLines = stdout.ToString().Split(SplitChars, StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(2, stdoutLines.Length); + Assert.Equal("vehicles/adder.ydr", stdoutLines[0]); + Assert.Equal("vehicles/zentorno.ydr", stdoutLines[1]); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } [Fact] - public void HasGlobChars_Empty_ReturnsFalse() => - Assert.False(SearchHandler.HasGlobChars("")); + public void PrintSearch_MultiRpf_SummaryIncludesArchiveCount() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = new() + { + Success = true, + RpfFile = "/dir", + RpfFiles = ["/dir/a.rpf", "/dir/b.rpf"], + Pattern = "adder", + PatternType = "substring", + MatchCount = 3, + Matches = + [ + MakeMatch("/dir/a.rpf", "vehicles/adder.ydr", "adder.ydr"), + MakeMatch("/dir/a.rpf", "vehicles/adder.ytd", "adder.ytd"), + MakeMatch("/dir/b.rpf", "vehicles/adder.yft", "adder.yft"), + ], + ErrorMessages = [], + }; + + SearchHandler.PrintSearch(result, MakeOptions()); + + string errOutput = stderr.ToString(); + Assert.Contains("Found 3 matches across 2 archive(s)", errOutput); + Assert.Contains("'adder'", errOutput); + Assert.Contains("(substring)", errOutput); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } [Fact] - public void HasGlobChars_HexHash_ReturnsFalse() => - Assert.False(SearchHandler.HasGlobChars("0xABCD1234")); + public void PrintSearch_SingleRpf_NoArchiveHeaders() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = new() + { + Success = true, + RpfFile = "/test.rpf", + RpfFiles = ["/test.rpf"], + Pattern = "*.ydr", + PatternType = "glob", + MatchCount = 1, + Matches = + [ + MakeMatch("/test.rpf", "vehicles/adder.ydr", "adder.ydr"), + ], + ErrorMessages = [], + }; + + SearchHandler.PrintSearch(result, MakeOptions()); + + string errOutput = stderr.ToString(); + Assert.DoesNotContain("==", errOutput); + Assert.Contains("Found 1 match for", errOutput); + Assert.DoesNotContain("archive(s)", errOutput); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } [Fact] - public void HasGlobChars_DecimalNumber_ReturnsFalse() => - Assert.False(SearchHandler.HasGlobChars("123456789")); + public void PrintSearch_MultiRpf_Verbose_ShowsSizeAndArchiveHeaders() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = new() + { + Success = true, + RpfFile = "/dir", + RpfFiles = ["/dir/a.rpf", "/dir/b.rpf"], + Pattern = "*.ydr", + PatternType = "glob", + MatchCount = 2, + Matches = + [ + MakeMatch("/dir/a.rpf", "vehicles/adder.ydr", "adder.ydr"), + MakeMatch("/dir/b.rpf", "vehicles/zentorno.ydr", "zentorno.ydr"), + ], + ErrorMessages = [], + }; + + SearchHandler.PrintSearch(result, MakeOptions(verbose: true)); + + string errOutput = stderr.ToString(); + Assert.Contains("== a.rpf ==", errOutput); + Assert.Contains("== b.rpf ==", errOutput); + + string stdoutOutput = stdout.ToString(); + Assert.Contains("1 KiB", stdoutOutput); + Assert.Contains("vehicles/adder.ydr", stdoutOutput); + Assert.Contains("vehicles/zentorno.ydr", stdoutOutput); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } +} + +// ── RelativePath ──────────────────────────────────────────────────── +public sealed class SearchRelativePathTests +{ [Fact] - public void HasGlobChars_PathWithoutGlob_ReturnsFalse() => - Assert.False(SearchHandler.HasGlobChars("vehicles/adder.ydr")); + public void RelativePath_StripsPrefixCorrectly() + { + string result = SearchHandler.RelativePath("/dir", "/dir/a.rpf"); + Assert.Equal("a.rpf", result); + } [Fact] - public void HasGlobChars_GlobstarPattern_ReturnsTrue() => - Assert.True(SearchHandler.HasGlobChars("**/vehicles/*.ydr")); + public void RelativePath_NestedPath_StripsFullPrefix() + { + string result = SearchHandler.RelativePath("/base/dir", "/base/dir/sub/deep/file.rpf"); + Assert.Equal("sub/deep/file.rpf", result); + } [Fact] - public void HasGlobChars_QuestionMarkOnly_ReturnsTrue() => - Assert.True(SearchHandler.HasGlobChars("?")); + public void RelativePath_HandlesBackslashes() + { + string result = SearchHandler.RelativePath("C:\\dir", "C:\\dir\\sub\\a.rpf"); + Assert.Equal("sub/a.rpf", result); + } [Fact] - public void HasGlobChars_AsteriskOnly_ReturnsTrue() => - Assert.True(SearchHandler.HasGlobChars("*")); + public void RelativePath_CaseInsensitiveMatch() + { + string result = SearchHandler.RelativePath("/DIR", "/dir/a.rpf"); + Assert.Equal("a.rpf", result); + } - // ── Additional HasGlobChars edge cases ──────────────────────────── + [Fact] + public void RelativePath_BaseAlreadyHasTrailingSlash() + { + string result = SearchHandler.RelativePath("/dir/", "/dir/a.rpf"); + Assert.Equal("a.rpf", result); + } [Fact] - public void HasGlobChars_BracketPattern_ReturnsFalse() => - Assert.False(SearchHandler.HasGlobChars("[abc]")); + public void RelativePath_NoCommonPrefix_FallsBackToFileName() + { + string result = SearchHandler.RelativePath("/other", "/dir/a.rpf"); + Assert.Equal("a.rpf", result); + } [Fact] - public void HasGlobChars_AsteriskInMiddle_ReturnsTrue() => - Assert.True(SearchHandler.HasGlobChars("foo*bar")); + public void RelativePath_MixedForwardAndBackslash() + { + string result = SearchHandler.RelativePath("/base/dir", "/base/dir\\sub\\a.rpf"); + Assert.Equal("sub/a.rpf", result); + } } +// ── ExecuteDirectory ──────────────────────────────────────────────── + [Collection("ConsoleOutput")] -public sealed class SearchHandlerExecuteTests +public sealed class SearchExecuteDirectoryTests { - private static RpfOptions MakeOptions(string rpfPath, bool json) => + private static RpfOptions MakeOptions(bool json = false) => new() { - RpfPath = rpfPath, + RpfPath = "", ExePath = "/nonexistent", Gen9 = false, Filters = [], @@ -85,7 +1345,7 @@ private static RpfOptions MakeOptions(string rpfPath, bool json) => }; [Fact] - public void Execute_MissingRpf_ReturnsOne() + public void ExecuteDirectory_DirNotFound_ReturnsOne() { TextWriter origOut = Console.Out; TextWriter origErr = Console.Error; @@ -95,10 +1355,14 @@ public void Execute_MissingRpf_ReturnsOne() Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), "*.ydr", TestContext.Current.CancellationToken); + int exitCode = SearchHandler.ExecuteDirectory( + MakeOptions(), + "*.ydr", + "/nonexistent_dir_xyz_12345", + TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); - Assert.Contains("Error:", stderr.ToString()); + Assert.Contains("Directory not found", stderr.ToString()); } finally { @@ -108,7 +1372,7 @@ public void Execute_MissingRpf_ReturnsOne() } [Fact] - public void Execute_MissingRpf_Json_ReturnsErrorJson() + public void ExecuteDirectory_DirNotFound_Json_ReturnsErrorJson() { TextWriter origOut = Console.Out; TextWriter origErr = Console.Error; @@ -118,12 +1382,16 @@ public void Execute_MissingRpf_Json_ReturnsErrorJson() Console.SetOut(stdout); Console.SetError(new StringWriter()); - int exitCode = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "adder", TestContext.Current.CancellationToken); + int exitCode = SearchHandler.ExecuteDirectory( + MakeOptions(json: true), + "adder", + "/nonexistent_dir_xyz_12345", + TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); string output = stdout.ToString(); Assert.Contains("\"success\": false", output); - Assert.Contains("RPF file not found", output); + Assert.Contains("Directory not found", output); } finally { @@ -133,21 +1401,95 @@ public void Execute_MissingRpf_Json_ReturnsErrorJson() } [Fact] - public void Execute_Json_ErrorContainsExpectedFields() + public void ExecuteDirectory_NoRpfFiles_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + string tempDir = Path.Combine(Path.GetTempPath(), $"cw_test_{Guid.NewGuid():N}"); + _ = Directory.CreateDirectory(tempDir); + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = SearchHandler.ExecuteDirectory( + MakeOptions(), + "*.ydr", + tempDir, + TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("No .rpf files found", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void ExecuteDirectory_NoRpfFiles_Json_ReturnsErrorJson() { TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + string tempDir = Path.Combine(Path.GetTempPath(), $"cw_test_{Guid.NewGuid():N}"); + _ = Directory.CreateDirectory(tempDir); try { StringWriter stdout = new(); Console.SetOut(stdout); + Console.SetError(new StringWriter()); - _ = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "test*", TestContext.Current.CancellationToken); + int exitCode = SearchHandler.ExecuteDirectory( + MakeOptions(json: true), + "adder", + tempDir, + TestContext.Current.CancellationToken); + Assert.Equal(1, exitCode); string output = stdout.ToString(); - Assert.Contains("\"rpfFile\":", output); - Assert.Contains("\"pattern\":", output); - Assert.Contains("\"matchCount\": 0", output); + Assert.Contains("\"success\": false", output); + Assert.Contains("No .rpf files found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void ExecuteDirectory_ExeValidationFails_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + string tempDir = Path.Combine(Path.GetTempPath(), $"cw_test_{Guid.NewGuid():N}"); + _ = Directory.CreateDirectory(tempDir); + File.WriteAllText(Path.Combine(tempDir, "fake.rpf"), ""); + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = SearchHandler.ExecuteDirectory( + MakeOptions(), + "*.ydr", + tempDir, + TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("GTA5.exe not found", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + Directory.Delete(tempDir, true); } - finally { Console.SetOut(origOut); } } } From 4dcc7a03b6e2c6e0777f08d6a10d86eaa7738360 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:07:55 +0200 Subject: [PATCH 28/45] refactor(cli): give diff independent options per side diff compared two archives through one CommonOptions, so both sides shared a single --exe and a single --gen9. Comparing a legacy installation against an Enhanced one, which is the reason to run it, was not expressible. The options record is flat and names each side: --left-exe, --right-exe, --left-gen9, --right-gen9. --progress is accepted here too, and the handler is split into CollectDiff and the two Print steps like the others. --- CodeWalker.Cli/CommonOptions.cs | 5 +- CodeWalker.Cli/Handlers/DiffHandler.cs | 511 ++++++++++-------- .../Tests/Handlers/DiffHandlerTests.cs | 36 +- 3 files changed, 316 insertions(+), 236 deletions(-) diff --git a/CodeWalker.Cli/CommonOptions.cs b/CodeWalker.Cli/CommonOptions.cs index ec4ddbab2..551b9e2f1 100644 --- a/CodeWalker.Cli/CommonOptions.cs +++ b/CodeWalker.Cli/CommonOptions.cs @@ -60,9 +60,10 @@ public CommonCommandOptions() }); } - public void AddTo(Command command, bool includeThreads = true) + public void AddTo(Command command, bool includeThreads = true, bool includeExe = true) { - command.Add(this.Exe); + if (includeExe) + command.Add(this.Exe); command.Add(this.Verbose); command.Add(this.Json); command.Add(this.Si); diff --git a/CodeWalker.Cli/Handlers/DiffHandler.cs b/CodeWalker.Cli/Handlers/DiffHandler.cs index d3b361197..27cb01a4f 100644 --- a/CodeWalker.Cli/Handlers/DiffHandler.cs +++ b/CodeWalker.Cli/Handlers/DiffHandler.cs @@ -16,9 +16,16 @@ internal sealed record DiffOptions { public required string LeftPath { get; init; } public required string RightPath { get; init; } - public required CommonOptions Common { get; init; } - public required bool Gen9 { get; init; } + public required string LeftExePath { get; init; } + public required string RightExePath { get; init; } + public required bool LeftGen9 { get; init; } + public required bool RightGen9 { get; init; } public required bool Recursive { get; init; } + public required bool Progress { get; init; } + public required bool Verbose { get; init; } + public required bool Json { get; init; } + public required SizeFormat SizeFormat { get; init; } + public required int Threads { get; init; } } internal static class DiffHandler @@ -38,9 +45,26 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul Required = true, }; - Option gen9Option = new("--gen9", "-g") + Option leftExeOption = new("--left-exe", "-le") { - Description = "Use GTA V Enhanced (Gen9) mode", + Description = "Path to the GTA V installation for the left archive", + Required = true, + }; + + Option rightExeOption = new("--right-exe", "-re") + { + Description = "Path to the GTA V installation for the right archive", + Required = true, + }; + + Option leftGen9Option = new("--left-gen9", "-lg") + { + Description = "Use GTA V Enhanced (Gen9) mode for the left archive", + }; + + Option rightGen9Option = new("--right-gen9", "-rg") + { + Description = "Use GTA V Enhanced (Gen9) mode for the right archive", }; Option recursiveOption = new("--recursive", "-R") @@ -48,14 +72,23 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul Description = "Include nested RPFs in comparison", }; + Option progressOption = new("--progress", "-P") + { + Description = "Show progress bar", + }; + Command command = new("diff", "Compare two RPF archives") { leftOption, rightOption, - gen9Option, + leftExeOption, + rightExeOption, + leftGen9Option, + rightGen9Option, recursiveOption, + progressOption, }; - commonOpts.AddTo(command); + commonOpts.AddTo(command, includeExe: false); command.Aliases.Add("d"); command.SetAction(parseResult => @@ -64,9 +97,16 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul { LeftPath = parseResult.GetRequiredValue(leftOption).FullName, RightPath = parseResult.GetRequiredValue(rightOption).FullName, - Common = commonOpts.Parse(parseResult), - Gen9 = parseResult.GetValue(gen9Option), + LeftExePath = parseResult.GetRequiredValue(leftExeOption).FullName, + RightExePath = parseResult.GetRequiredValue(rightExeOption).FullName, + LeftGen9 = parseResult.GetValue(leftGen9Option), + RightGen9 = parseResult.GetValue(rightGen9Option), Recursive = parseResult.GetValue(recursiveOption), + Progress = parseResult.GetValue(progressOption), + Verbose = parseResult.GetValue(commonOpts.Verbose), + Json = parseResult.GetValue(commonOpts.Json), + SizeFormat = parseResult.GetValue(commonOpts.Si) ? SizeFormat.SI : SizeFormat.IEC, + Threads = parseResult.GetValue(commonOpts.Threads), }; return Execute(options, cancellationToken); }); @@ -76,100 +116,141 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul public static int Execute(DiffOptions options, CancellationToken cancellationToken = default) { - Json.DiffResult ErrorResult(string[] errorMessages) => - new() - { - Success = false, - LeftRpf = options.LeftPath, - RightRpf = options.RightPath, - Added = [], - Removed = [], - Modified = [], - Unchanged = [], - Summary = new Json.DiffSummary - { - AddedCount = 0, - RemovedCount = 0, - ModifiedCount = 0, - UnchangedCount = 0, - }, - ErrorMessages = errorMessages, - }; - - // Validate both RPF files exist before loading keys + // Validate both RPF files and exe paths before loading keys string? leftError = RpfService.ValidateInputs( options.LeftPath, - options.Common.ExePath, - options.Gen9 + options.LeftExePath, + options.LeftGen9 ); if (leftError != null) { - return RpfService.ReportError(leftError, options.Common.Json, ErrorResult([])); + return RpfService.ReportError( + leftError, + options.Json, + ErrorResult([], options) + ); } - if (!File.Exists(options.RightPath)) + string? rightError = RpfService.ValidateInputs( + options.RightPath, + options.RightExePath, + options.RightGen9 + ); + if (rightError != null) { return RpfService.ReportError( - $"RPF file not found: {options.RightPath}", - options.Common.Json, - ErrorResult([]) + rightError, + options.Json, + ErrorResult([], options) ); } + List errorMessages = []; try { - if (!options.Common.Json) + if (!options.Json) Console.Error.WriteLine("Loading encryption keys..."); - RpfService.LoadKeys(options.Common.ExePath, options.Gen9); - - List errorMessages = []; + RpfService.LoadKeys(options.LeftExePath, options.LeftGen9); + if (options.RightExePath != options.LeftExePath || options.RightGen9 != options.LeftGen9) + RpfService.LoadKeys(options.RightExePath, options.RightGen9); RpfFile leftRpf = RpfService.OpenRpf( options.LeftPath, - options.Common.Verbose, - options.Common.Json, + options.Verbose, + options.Json, errorMessages ); RpfFile rightRpf = RpfService.OpenRpf( options.RightPath, - options.Common.Verbose, - options.Common.Json, + options.Verbose, + options.Json, errorMessages ); - // Collect files from both archives - List<(RpfFile rpf, RpfFileEntry entry)> leftFiles = RpfService.CollectFiles( - leftRpf, - null, - options.Recursive - ); - List<(RpfFile rpf, RpfFileEntry entry)> rightFiles = RpfService.CollectFiles( - rightRpf, - null, - options.Recursive + Json.DiffResult result = CollectDiff(leftRpf, rightRpf, errorMessages, options, cancellationToken); + + if (options.Json) + PrintJsonDiff(result); + else + PrintDiff(result, options); + + return errorMessages.Count > 0 ? 1 : 0; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + return RpfService.ReportError( + ex.Message, + options.Json, + ErrorResult([.. errorMessages], options), + options.Verbose ? ex.StackTrace : null ); + } + } + + internal static Json.DiffResult ErrorResult(string[] errorMessages, DiffOptions options) => + new() + { + Success = false, + LeftRpf = options.LeftPath, + RightRpf = options.RightPath, + Added = [], + Removed = [], + Modified = [], + Unchanged = [], + Summary = new Json.DiffSummary + { + AddedCount = 0, + RemovedCount = 0, + ModifiedCount = 0, + UnchangedCount = 0, + }, + ErrorMessages = errorMessages, + }; - // Build dictionaries keyed by path - Dictionary leftDict = - leftFiles.ToDictionary(f => f.entry.Path, f => f); + internal static Json.DiffResult CollectDiff( + RpfFile leftRpf, + RpfFile rightRpf, + List errorMessages, + DiffOptions options, + CancellationToken cancellationToken = default) + { + // Collect files from both archives + List<(RpfFile rpf, RpfFileEntry entry)> leftFiles = RpfService.CollectFiles( + leftRpf, + null, + options.Recursive + ); + List<(RpfFile rpf, RpfFileEntry entry)> rightFiles = RpfService.CollectFiles( + rightRpf, + null, + options.Recursive + ); + + // Build dictionaries keyed by path + Dictionary leftDict = + leftFiles.ToDictionary(f => f.entry.Path, f => f); - Dictionary rightDict = - rightFiles.ToDictionary(f => f.entry.Path, f => f); + Dictionary rightDict = + rightFiles.ToDictionary(f => f.entry.Path, f => f); - SizeFormat sizeFormat = options.Common.SizeFormat; + SizeFormat sizeFormat = options.SizeFormat; - // Find removed and modified/unchanged — entries in left that also appear in right - // need byte comparison, so parallelize this - string[] commonPaths = leftDict.Keys.Where(rightDict.ContainsKey).ToArray(); + // Find removed and modified/unchanged — entries in left that also appear in right + // need byte comparison, so parallelize this + string[] commonPaths = leftDict.Keys.Where(rightDict.ContainsKey).ToArray(); - // Result per common path: null = unchanged, non-null = modified entry - bool[] isModifiedArr = new bool[commonPaths.Length]; + // Result per common path: false = unchanged, true = modified + bool[] isModifiedArr = new bool[commonPaths.Length]; + object errorLock = new(); + using (ProgressBar progress = new(commonPaths.Length, options.Progress && !options.Json)) + { _ = Parallel.For( 0, commonPaths.Length, - new ParallelOptions { MaxDegreeOfParallelism = options.Common.Threads, CancellationToken = cancellationToken }, + new ParallelOptions { MaxDegreeOfParallelism = options.Threads, CancellationToken = cancellationToken }, i => { string path = commonPaths[i]; @@ -189,186 +270,184 @@ Json.DiffResult ErrorResult(string[] errorMessages) => { byte[]? leftData = leftRpfRef.ExtractFile(leftEntry); byte[]? rightData = rightRpfRef.ExtractFile(rightEntry); - isModifiedArr[i] = !ContentEquals(leftData, rightData); - } - } - ); - List added = []; - List removed = []; - List modified = []; - List unchanged = []; - - // Aggregate common path results - for (int i = 0; i < commonPaths.Length; i++) - { - string path = commonPaths[i]; - (_, RpfFileEntry leftEntry) = leftDict[path]; - (_, RpfFileEntry rightEntry) = rightDict[path]; - - if (isModifiedArr[i]) - { - long leftSize = leftEntry.GetFileSize(); - long rightSize = rightEntry.GetFileSize(); - modified.Add( - new Json.DiffEntry + if (leftData == null || rightData == null) { - Path = path, - Name = leftEntry.Name, - Type = RpfService.GetFileType(leftEntry), - LeftSize = leftSize, - LeftSizeFormatted = sizeFormat.ToFormattedString(leftSize), - RightSize = rightSize, - RightSizeFormatted = sizeFormat.ToFormattedString(rightSize), + string side = leftData == null ? "left" : "right"; + lock (errorLock) + errorMessages.Add($"Failed to extract {side} entry: {path}"); + isModifiedArr[i] = true; } - ); - } - else - { - long size = leftEntry.GetFileSize(); - unchanged.Add( - new Json.DiffEntry + else { - Path = path, - Name = leftEntry.Name, - Type = RpfService.GetFileType(leftEntry), - Size = size, - SizeFormatted = sizeFormat.ToFormattedString(size), + isModifiedArr[i] = !ContentEquals(leftData, rightData); } - ); - } - } - - // Find removed (left only) - removed.AddRange( - leftDict - .Where(kvp => !rightDict.ContainsKey(kvp.Key)) - .Select(kvp => - { - long size = kvp.Value.entry.GetFileSize(); - return new Json.DiffEntry - { - Path = kvp.Key, - Name = kvp.Value.entry.Name, - Type = RpfService.GetFileType(kvp.Value.entry), - Size = size, - SizeFormatted = sizeFormat.ToFormattedString(size), - }; - }) - ); + } - // Find added (right only) - added.AddRange( - rightDict - .Where(kvp => !leftDict.ContainsKey(kvp.Key)) - .Select(kvp => - { - long size = kvp.Value.entry.GetFileSize(); - return new Json.DiffEntry - { - Path = kvp.Key, - Name = kvp.Value.entry.Name, - Type = RpfService.GetFileType(kvp.Value.entry), - Size = size, - SizeFormatted = sizeFormat.ToFormattedString(size), - }; - }) + progress.Increment(path); + } ); + } - // Sort alphabetically - added.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path)); - removed.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path)); - modified.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path)); - unchanged.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path)); - - Json.DiffSummary summary = new() - { - AddedCount = added.Count, - RemovedCount = removed.Count, - ModifiedCount = modified.Count, - UnchangedCount = unchanged.Count, - }; + List added = []; + List removed = []; + List modified = []; + List unchanged = []; - Json.DiffResult result = new() - { - Success = errorMessages.Count == 0, - LeftRpf = options.LeftPath, - RightRpf = options.RightPath, - Added = [.. added], - Removed = [.. removed], - Modified = [.. modified], - Unchanged = [.. unchanged], - Summary = summary, - ErrorMessages = [.. errorMessages], - }; + // Aggregate common path results + for (int i = 0; i < commonPaths.Length; i++) + { + string path = commonPaths[i]; + (_, RpfFileEntry leftEntry) = leftDict[path]; + (_, RpfFileEntry rightEntry) = rightDict[path]; - if (options.Common.Json) + if (isModifiedArr[i]) { - Console.WriteLine( - JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + long leftSize = leftEntry.GetFileSize(); + long rightSize = rightEntry.GetFileSize(); + modified.Add( + new Json.DiffEntry + { + Path = path, + Name = leftEntry.Name, + Type = RpfService.GetFileType(leftEntry), + LeftSize = leftSize, + LeftSizeFormatted = sizeFormat.ToFormattedString(leftSize), + RightSize = rightSize, + RightSizeFormatted = sizeFormat.ToFormattedString(rightSize), + } ); } else { - if (added.Count > 0) - { - Console.WriteLine($"Added ({added.Count}):"); - foreach (Json.DiffEntry entry in added) + long size = leftEntry.GetFileSize(); + unchanged.Add( + new Json.DiffEntry { - Console.WriteLine($" + {entry.Path}"); + Path = path, + Name = leftEntry.Name, + Type = RpfService.GetFileType(leftEntry), + Size = size, + SizeFormatted = sizeFormat.ToFormattedString(size), } - Console.WriteLine(); - } + ); + } + } - if (removed.Count > 0) + // Find removed (left only) + removed.AddRange( + leftDict + .Where(kvp => !rightDict.ContainsKey(kvp.Key)) + .Select(kvp => { - Console.WriteLine($"Removed ({removed.Count}):"); - foreach (Json.DiffEntry entry in removed) + long size = kvp.Value.entry.GetFileSize(); + return new Json.DiffEntry { - Console.WriteLine($" - {entry.Path}"); - } - Console.WriteLine(); - } + Path = kvp.Key, + Name = kvp.Value.entry.Name, + Type = RpfService.GetFileType(kvp.Value.entry), + Size = size, + SizeFormatted = sizeFormat.ToFormattedString(size), + }; + }) + ); - if (modified.Count > 0) + // Find added (right only) + added.AddRange( + rightDict + .Where(kvp => !leftDict.ContainsKey(kvp.Key)) + .Select(kvp => { - Console.WriteLine($"Modified ({modified.Count}):"); - foreach (Json.DiffEntry entry in modified) + long size = kvp.Value.entry.GetFileSize(); + return new Json.DiffEntry { - Console.WriteLine( - $" ~ {entry.Path} ({entry.LeftSizeFormatted} -> {entry.RightSizeFormatted})" - ); - } - Console.WriteLine(); - } + Path = kvp.Key, + Name = kvp.Value.entry.Name, + Type = RpfService.GetFileType(kvp.Value.entry), + Size = size, + SizeFormatted = sizeFormat.ToFormattedString(size), + }; + }) + ); - if (options.Common.Verbose && unchanged.Count > 0) - { - Console.WriteLine($"Unchanged ({unchanged.Count}):"); - foreach (Json.DiffEntry entry in unchanged) - { - Console.WriteLine($" = {entry.Path}"); - } - Console.WriteLine(); - } + // Sort alphabetically + added.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path)); + removed.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path)); + modified.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path)); + unchanged.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path)); - Console.Error.WriteLine( - $"Summary: {added.Count} added, {removed.Count} removed, {modified.Count} modified, {unchanged.Count} unchanged" - ); + Json.DiffSummary summary = new() + { + AddedCount = added.Count, + RemovedCount = removed.Count, + ModifiedCount = modified.Count, + UnchangedCount = unchanged.Count, + }; + + return new Json.DiffResult + { + Success = errorMessages.Count == 0, + LeftRpf = options.LeftPath, + RightRpf = options.RightPath, + Added = [.. added], + Removed = [.. removed], + Modified = [.. modified], + Unchanged = [.. unchanged], + Summary = summary, + ErrorMessages = [.. errorMessages], + }; + } + + internal static void PrintJsonDiff(Json.DiffResult result) => + Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); + + internal static void PrintDiff(Json.DiffResult result, DiffOptions options) + { + if (result.Added.Count > 0) + { + Console.WriteLine($"Added ({result.Added.Count}):"); + foreach (Json.DiffEntry entry in result.Added) + { + Console.WriteLine($" + {entry.Path}"); } + Console.WriteLine(); + } - return errorMessages.Count > 0 ? 1 : 0; + if (result.Removed.Count > 0) + { + Console.WriteLine($"Removed ({result.Removed.Count}):"); + foreach (Json.DiffEntry entry in result.Removed) + { + Console.WriteLine($" - {entry.Path}"); + } + Console.WriteLine(); } - catch (OperationCanceledException) { throw; } - catch (Exception ex) + + if (result.Modified.Count > 0) { - return RpfService.ReportError( - ex.Message, - options.Common.Json, - ErrorResult([]), - options.Common.Verbose ? ex.StackTrace : null - ); + Console.WriteLine($"Modified ({result.Modified.Count}):"); + foreach (Json.DiffEntry entry in result.Modified) + { + Console.WriteLine( + $" ~ {entry.Path} ({entry.LeftSizeFormatted} -> {entry.RightSizeFormatted})" + ); + } + Console.WriteLine(); } + + if (options.Verbose && result.Unchanged.Count > 0) + { + Console.WriteLine($"Unchanged ({result.Unchanged.Count}):"); + foreach (Json.DiffEntry entry in result.Unchanged) + { + Console.WriteLine($" = {entry.Path}"); + } + Console.WriteLine(); + } + + Console.Error.WriteLine( + $"Summary: {result.Summary.AddedCount} added, {result.Summary.RemovedCount} removed, {result.Summary.ModifiedCount} modified, {result.Summary.UnchangedCount} unchanged" + ); } internal static bool ContentEquals(byte[]? a, byte[]? b) diff --git a/CodeWalker.Cli/Tests/Handlers/DiffHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/DiffHandlerTests.cs index c951568e7..33f0af0f9 100644 --- a/CodeWalker.Cli/Tests/Handlers/DiffHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/DiffHandlerTests.cs @@ -88,16 +88,16 @@ private static DiffOptions MakeOptions(string leftPath, string rightPath, bool j { LeftPath = leftPath, RightPath = rightPath, - Common = new CommonOptions - { - ExePath = "/nonexistent", - Verbose = false, - Json = json, - SizeFormat = SizeFormat.IEC, - Threads = 1, - }, - Gen9 = false, + LeftExePath = "/nonexistent", + RightExePath = "/nonexistent", + LeftGen9 = false, + RightGen9 = false, Recursive = false, + Progress = false, + Verbose = false, + Json = json, + SizeFormat = SizeFormat.IEC, + Threads = 1, }; [Fact] @@ -171,16 +171,16 @@ public void Execute_RightMissing_WithExistingLeft_ReturnsOne() { LeftPath = leftRpf, RightPath = "/nonexistent/right.rpf", - Common = new CommonOptions - { - ExePath = dir, - Verbose = false, - Json = false, - SizeFormat = SizeFormat.IEC, - Threads = 1, - }, - Gen9 = false, + LeftExePath = dir, + RightExePath = dir, + LeftGen9 = false, + RightGen9 = false, Recursive = false, + Progress = false, + Verbose = false, + Json = false, + SizeFormat = SizeFormat.IEC, + Threads = 1, }; int exitCode = DiffHandler.Execute(options, TestContext.Current.CancellationToken); From 83d1a847823fd06d9c5c6b51d0a04baca7dfc477 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:07:08 +0200 Subject: [PATCH 29/45] refactor(cli): flatten shared options and move helpers into Helpers/ RpfCommandOptions, CommonCommandOptions and ExportCommandOptions each wrapped a group of options in a type that handlers had to compose, so the options records nested a level deep and every read went through another hop: options.Rpf.Recursive rather than options.Recursive. CliOptions replaces all three with one factory method per option. A handler lists the options it accepts, and its options record is flat. RpfService and ExportService move under Helpers/ as RpfHelper and ExportPipeline, and the BaseResult and ReportError pair they had accumulated moves out to its own Output.cs. --- CodeWalker.Cli/CommonOptions.cs | 82 --------- CodeWalker.Cli/Handlers/DiffHandler.cs | 58 +++--- CodeWalker.Cli/Handlers/ExportAudioHandler.cs | 45 ++++- CodeWalker.Cli/Handlers/ExportHandler.cs | 54 ------ CodeWalker.Cli/Handlers/ExportTextHandler.cs | 45 ++++- .../Handlers/ExportTexturesHandler.cs | 45 ++++- CodeWalker.Cli/Handlers/ExportXmlHandler.cs | 42 ++++- CodeWalker.Cli/Handlers/ExtractHandler.cs | 126 +++++++------ CodeWalker.Cli/Handlers/Gen9Handler.cs | 79 +++++---- CodeWalker.Cli/Handlers/HashHandler.cs | 5 +- CodeWalker.Cli/Handlers/InspectHandler.cs | 73 ++++++-- CodeWalker.Cli/Handlers/ListHandler.cs | 74 ++++++-- CodeWalker.Cli/Handlers/PackHandler.cs | 68 ++++--- CodeWalker.Cli/Handlers/SearchHandler.cs | 131 +++++++++----- CodeWalker.Cli/Handlers/StatHandler.cs | 72 ++++++-- CodeWalker.Cli/Handlers/TreeHandler.cs | 96 ++++++---- CodeWalker.Cli/Handlers/ValidateHandler.cs | 98 ++++++---- CodeWalker.Cli/Helpers/CliOptions.cs | 90 ++++++++++ .../ExportPipeline.cs} | 75 ++++---- CodeWalker.Cli/Helpers/Output.cs | 57 ++++++ .../{RpfService.cs => Helpers/RpfHelper.cs} | 167 ++++++------------ CodeWalker.Cli/Json/DiffResult.cs | 2 + CodeWalker.Cli/Json/ExportResult.cs | 2 + CodeWalker.Cli/Json/ExtractResult.cs | 2 + CodeWalker.Cli/Json/Gen9Result.cs | 2 + CodeWalker.Cli/Json/HashResult.cs | 2 + CodeWalker.Cli/Json/InspectResult.cs | 2 + CodeWalker.Cli/Json/ListResult.cs | 2 + CodeWalker.Cli/Json/PackResult.cs | 2 + CodeWalker.Cli/Json/SearchResult.cs | 2 + CodeWalker.Cli/Json/StatResult.cs | 2 + CodeWalker.Cli/Json/TreeResult.cs | 2 + CodeWalker.Cli/Json/ValidateResult.cs | 2 + CodeWalker.Cli/RpfOptions.cs | 79 --------- CodeWalker.Cli/Tests/CliOptionsTests.cs | 154 ++++++++++++++++ CodeWalker.Cli/Tests/CommonOptionsTests.cs | 91 ---------- CodeWalker.Cli/Tests/ExportOptionsTests.cs | 83 --------- ...ServiceTests.cs => ExportPipelineTests.cs} | 72 ++++---- .../Tests/Handlers/ExtractHandlerTests.cs | 21 +-- .../Tests/Handlers/Gen9HandlerTests.cs | 13 +- .../Tests/Handlers/HashHandlerTests.cs | 11 +- .../Tests/Handlers/InspectHandlerTests.cs | 10 +- .../Tests/Handlers/ListHandlerTests.cs | 3 +- .../Tests/Handlers/PackHandlerTests.cs | 12 +- .../Tests/Handlers/SearchHandlerTests.cs | 129 +++++++------- .../Tests/Handlers/StatHandlerTests.cs | 20 +-- .../Tests/Handlers/TreeHandlerTests.cs | 158 +++++++---------- .../Tests/Handlers/ValidateHandlerTests.cs | 21 +-- .../{RpfServiceTests.cs => RpfHelperTests.cs} | 73 ++++---- CodeWalker.Cli/Tests/RpfOptionsTests.cs | 95 ---------- 50 files changed, 1399 insertions(+), 1252 deletions(-) delete mode 100644 CodeWalker.Cli/CommonOptions.cs create mode 100644 CodeWalker.Cli/Helpers/CliOptions.cs rename CodeWalker.Cli/{ExportService.cs => Helpers/ExportPipeline.cs} (83%) create mode 100644 CodeWalker.Cli/Helpers/Output.cs rename CodeWalker.Cli/{RpfService.cs => Helpers/RpfHelper.cs} (78%) delete mode 100644 CodeWalker.Cli/RpfOptions.cs create mode 100644 CodeWalker.Cli/Tests/CliOptionsTests.cs delete mode 100644 CodeWalker.Cli/Tests/CommonOptionsTests.cs delete mode 100644 CodeWalker.Cli/Tests/ExportOptionsTests.cs rename CodeWalker.Cli/Tests/{ExportServiceTests.cs => ExportPipelineTests.cs} (82%) rename CodeWalker.Cli/Tests/{RpfServiceTests.cs => RpfHelperTests.cs} (83%) delete mode 100644 CodeWalker.Cli/Tests/RpfOptionsTests.cs diff --git a/CodeWalker.Cli/CommonOptions.cs b/CodeWalker.Cli/CommonOptions.cs deleted file mode 100644 index 551b9e2f1..000000000 --- a/CodeWalker.Cli/CommonOptions.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.CommandLine; -using System.Diagnostics.CodeAnalysis; -using System.IO; - -using CodeWalker.Cli.Helpers; - -namespace CodeWalker.Cli; - -[ExcludeFromCodeCoverage] -internal sealed record CommonOptions -{ - public required string ExePath { get; init; } - public required bool Verbose { get; init; } - public required bool Json { get; init; } - public required SizeFormat SizeFormat { get; init; } - public required int Threads { get; init; } -} - -/// -/// Shared System.CommandLine option definitions for --exe, --verbose, --json, --si, --threads. -/// Create an instance, call to register options on a command, -/// then call inside the action to build a . -/// -internal sealed class CommonCommandOptions -{ - public Option Exe { get; } = new("--exe", "-e") - { - Description = "Path to the GTA V installation directory (containing GTA5.exe)", - Required = true, - }; - - public Option Verbose { get; } = new("--verbose", "-v") - { - Description = "Show verbose output", - }; - - public Option Json { get; } = new("--json") - { - Description = "Output results in JSON format for scripting", - }; - - public Option Si { get; } = new("--si") - { - Description = "Use SI units (1000-based: KB, MB) instead of IEC (1024-based: KiB, MiB)", - }; - - public Option Threads { get; } = new("--threads", "-t") - { - Description = "Number of threads for parallel processing", - DefaultValueFactory = _ => Environment.ProcessorCount, - }; - - public CommonCommandOptions() - { - this.Threads.Validators.Add(result => - { - if (result.GetValue(this.Threads) < 1) - result.AddError("--threads must be at least 1."); - }); - } - - public void AddTo(Command command, bool includeThreads = true, bool includeExe = true) - { - if (includeExe) - command.Add(this.Exe); - command.Add(this.Verbose); - command.Add(this.Json); - command.Add(this.Si); - if (includeThreads) - command.Add(this.Threads); - } - - public CommonOptions Parse(ParseResult parseResult) => new() - { - ExePath = parseResult.GetRequiredValue(this.Exe).FullName, - Verbose = parseResult.GetValue(this.Verbose), - Json = parseResult.GetValue(this.Json), - SizeFormat = parseResult.GetValue(this.Si) ? SizeFormat.SI : SizeFormat.IEC, - Threads = parseResult.GetValue(this.Threads), - }; -} diff --git a/CodeWalker.Cli/Handlers/DiffHandler.cs b/CodeWalker.Cli/Handlers/DiffHandler.cs index 27cb01a4f..d2d1129fb 100644 --- a/CodeWalker.Cli/Handlers/DiffHandler.cs +++ b/CodeWalker.Cli/Handlers/DiffHandler.cs @@ -32,7 +32,11 @@ internal static class DiffHandler { public static Command CreateCommand(CancellationToken cancellationToken = default) { - CommonCommandOptions commonOpts = new(); + Option verboseOpt = CliOptions.Verbose(); + Option jsonOpt = CliOptions.Json(); + Option siOpt = CliOptions.Si(); + Option threadsOpt = CliOptions.Threads(); + Option leftOption = new("--left", "-l") { Description = "First RPF archive to compare", @@ -77,6 +81,7 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul Description = "Show progress bar", }; + Command command = new("diff", "Compare two RPF archives") { leftOption, @@ -86,9 +91,14 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul leftGen9Option, rightGen9Option, recursiveOption, + progressOption, + verboseOpt, + jsonOpt, + siOpt, + threadsOpt }; - commonOpts.AddTo(command, includeExe: false); + command.Aliases.Add("d"); command.SetAction(parseResult => @@ -103,10 +113,10 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul RightGen9 = parseResult.GetValue(rightGen9Option), Recursive = parseResult.GetValue(recursiveOption), Progress = parseResult.GetValue(progressOption), - Verbose = parseResult.GetValue(commonOpts.Verbose), - Json = parseResult.GetValue(commonOpts.Json), - SizeFormat = parseResult.GetValue(commonOpts.Si) ? SizeFormat.SI : SizeFormat.IEC, - Threads = parseResult.GetValue(commonOpts.Threads), + Verbose = parseResult.GetValue(verboseOpt), + Json = parseResult.GetValue(jsonOpt), + SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC, + Threads = parseResult.GetValue(threadsOpt), }; return Execute(options, cancellationToken); }); @@ -117,28 +127,28 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul public static int Execute(DiffOptions options, CancellationToken cancellationToken = default) { // Validate both RPF files and exe paths before loading keys - string? leftError = RpfService.ValidateInputs( + string? leftError = RpfHelper.ValidateInputs( options.LeftPath, options.LeftExePath, options.LeftGen9 ); if (leftError != null) { - return RpfService.ReportError( + return Output.ReportError( leftError, options.Json, ErrorResult([], options) ); } - string? rightError = RpfService.ValidateInputs( + string? rightError = RpfHelper.ValidateInputs( options.RightPath, options.RightExePath, options.RightGen9 ); if (rightError != null) { - return RpfService.ReportError( + return Output.ReportError( rightError, options.Json, ErrorResult([], options) @@ -150,18 +160,18 @@ public static int Execute(DiffOptions options, CancellationToken cancellationTok { if (!options.Json) Console.Error.WriteLine("Loading encryption keys..."); - RpfService.LoadKeys(options.LeftExePath, options.LeftGen9); + RpfHelper.LoadKeys(options.LeftExePath, options.LeftGen9); if (options.RightExePath != options.LeftExePath || options.RightGen9 != options.LeftGen9) - RpfService.LoadKeys(options.RightExePath, options.RightGen9); + RpfHelper.LoadKeys(options.RightExePath, options.RightGen9); - RpfFile leftRpf = RpfService.OpenRpf( + RpfFile leftRpf = RpfHelper.OpenRpf( options.LeftPath, options.Verbose, options.Json, errorMessages ); - RpfFile rightRpf = RpfService.OpenRpf( + RpfFile rightRpf = RpfHelper.OpenRpf( options.RightPath, options.Verbose, options.Json, @@ -180,7 +190,7 @@ public static int Execute(DiffOptions options, CancellationToken cancellationTok catch (OperationCanceledException) { throw; } catch (Exception ex) { - return RpfService.ReportError( + return Output.ReportError( ex.Message, options.Json, ErrorResult([.. errorMessages], options), @@ -217,12 +227,12 @@ internal static Json.DiffResult CollectDiff( CancellationToken cancellationToken = default) { // Collect files from both archives - List<(RpfFile rpf, RpfFileEntry entry)> leftFiles = RpfService.CollectFiles( + List<(RpfFile rpf, RpfFileEntry entry)> leftFiles = RpfHelper.CollectFiles( leftRpf, null, options.Recursive ); - List<(RpfFile rpf, RpfFileEntry entry)> rightFiles = RpfService.CollectFiles( + List<(RpfFile rpf, RpfFileEntry entry)> rightFiles = RpfHelper.CollectFiles( rightRpf, null, options.Recursive @@ -259,8 +269,8 @@ internal static Json.DiffResult CollectDiff( long leftSize = leftEntry.GetFileSize(); long rightSize = rightEntry.GetFileSize(); - string leftType = RpfService.GetFileType(leftEntry); - string rightType = RpfService.GetFileType(rightEntry); + string leftType = RpfHelper.GetFileType(leftEntry); + string rightType = RpfHelper.GetFileType(rightEntry); if (leftSize != rightSize || leftType != rightType) { @@ -310,7 +320,7 @@ internal static Json.DiffResult CollectDiff( { Path = path, Name = leftEntry.Name, - Type = RpfService.GetFileType(leftEntry), + Type = RpfHelper.GetFileType(leftEntry), LeftSize = leftSize, LeftSizeFormatted = sizeFormat.ToFormattedString(leftSize), RightSize = rightSize, @@ -326,7 +336,7 @@ internal static Json.DiffResult CollectDiff( { Path = path, Name = leftEntry.Name, - Type = RpfService.GetFileType(leftEntry), + Type = RpfHelper.GetFileType(leftEntry), Size = size, SizeFormatted = sizeFormat.ToFormattedString(size), } @@ -345,7 +355,7 @@ internal static Json.DiffResult CollectDiff( { Path = kvp.Key, Name = kvp.Value.entry.Name, - Type = RpfService.GetFileType(kvp.Value.entry), + Type = RpfHelper.GetFileType(kvp.Value.entry), Size = size, SizeFormatted = sizeFormat.ToFormattedString(size), }; @@ -363,7 +373,7 @@ internal static Json.DiffResult CollectDiff( { Path = kvp.Key, Name = kvp.Value.entry.Name, - Type = RpfService.GetFileType(kvp.Value.entry), + Type = RpfHelper.GetFileType(kvp.Value.entry), Size = size, SizeFormatted = sizeFormat.ToFormattedString(size), }; @@ -399,7 +409,7 @@ internal static Json.DiffResult CollectDiff( } internal static void PrintJsonDiff(Json.DiffResult result) => - Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); + Console.WriteLine(JsonSerializer.Serialize(result, Output.JsonSerializerOptions)); internal static void PrintDiff(Json.DiffResult result, DiffOptions options) { diff --git a/CodeWalker.Cli/Handlers/ExportAudioHandler.cs b/CodeWalker.Cli/Handlers/ExportAudioHandler.cs index 7f5fca3fe..743c8f63b 100644 --- a/CodeWalker.Cli/Handlers/ExportAudioHandler.cs +++ b/CodeWalker.Cli/Handlers/ExportAudioHandler.cs @@ -2,6 +2,7 @@ using System.IO; using System.Threading; +using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; namespace CodeWalker.Cli.Handlers; @@ -12,21 +13,49 @@ internal static class ExportAudioHandler public static Command CreateCommand(CancellationToken cancellationToken = default) { - ExportCommandOptions exportOpts = new(); + Option rpfOpt = CliOptions.Rpf(); + Option exeOpt = CliOptions.Exe(); + Option gen9Opt = CliOptions.Gen9(); + Option filterOpt = CliOptions.Filter(); + Option recursiveOpt = CliOptions.Recursive(); + Option verboseOpt = CliOptions.Verbose(); + Option jsonOpt = CliOptions.Json(); + Option siOpt = CliOptions.Si(); + Option threadsOpt = CliOptions.Threads(); + Option outputOpt = CliOptions.OutputDir(); + Option dryRunOpt = CliOptions.DryRun(); + Option noOverwriteOpt = CliOptions.NoOverwrite(); + Option progressOpt = CliOptions.Progress(); - Command command = new("audio", "Export .awc audio containers to WAV/MIDI files"); - exportOpts.AddTo(command); + Command command = new("audio", "Export .awc audio containers to WAV/MIDI files") + { + rpfOpt, exeOpt, gen9Opt, filterOpt, recursiveOpt, + verboseOpt, jsonOpt, siOpt, threadsOpt, + outputOpt, dryRunOpt, noOverwriteOpt, progressOpt, + }; command.Aliases.Add("a"); command.Aliases.Add("awc"); command.SetAction(parseResult => { - ExportOptions options = exportOpts.Parse(parseResult); - if (options.Rpf.Filters.Length == 0) + string[] filters = Filter.Normalize(parseResult.GetValue(filterOpt)); + ExportOptions options = new() { - options = options with { Rpf = options.Rpf with { Filters = DefaultFilters } }; - } - return ExportService.Execute(options, "wav", "Audio", ProcessFile, cancellationToken); + RpfPath = parseResult.GetValue(rpfOpt)?.FullName ?? "", + ExePath = parseResult.GetRequiredValue(exeOpt).FullName, + Gen9 = parseResult.GetValue(gen9Opt), + Filters = filters.Length == 0 ? DefaultFilters : filters, + Verbose = parseResult.GetValue(verboseOpt), + Json = parseResult.GetValue(jsonOpt), + Recursive = parseResult.GetValue(recursiveOpt), + Threads = parseResult.GetValue(threadsOpt), + SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC, + OutputPath = parseResult.GetValue(outputOpt)?.FullName ?? Directory.GetCurrentDirectory(), + DryRun = parseResult.GetValue(dryRunOpt), + NoOverwrite = parseResult.GetValue(noOverwriteOpt), + Progress = parseResult.GetValue(progressOpt), + }; + return ExportPipeline.Execute(options, "wav", "Audio", ProcessFile, cancellationToken); }); return command; diff --git a/CodeWalker.Cli/Handlers/ExportHandler.cs b/CodeWalker.Cli/Handlers/ExportHandler.cs index 6c7be2749..e20110ef4 100644 --- a/CodeWalker.Cli/Handlers/ExportHandler.cs +++ b/CodeWalker.Cli/Handlers/ExportHandler.cs @@ -1,62 +1,8 @@ using System.CommandLine; -using System.IO; using System.Threading; namespace CodeWalker.Cli.Handlers; -internal sealed record ExportOptions -{ - public required RpfOptions Rpf { get; init; } - public required string OutputPath { get; init; } - public required bool DryRun { get; init; } - public required bool NoOverwrite { get; init; } - public required bool Progress { get; init; } -} - -internal sealed class ExportCommandOptions -{ - private readonly RpfCommandOptions _rpfOpts = new(); - - public Option Output { get; } = new("--output", "-o") - { - Description = "Output directory", - DefaultValueFactory = _ => new DirectoryInfo(Directory.GetCurrentDirectory()), - }; - - public Option DryRun { get; } = new("--dry-run", "-n") - { - Description = "Show what would be exported without writing files", - }; - - public Option NoOverwrite { get; } = new("--no-overwrite") - { - Description = "Skip existing output files instead of overwriting", - }; - - public Option Progress { get; } = new("--progress", "-P") - { - Description = "Show progress bar during export", - }; - - public void AddTo(Command command) - { - this._rpfOpts.AddTo(command); - command.Add(this.Output); - command.Add(this.DryRun); - command.Add(this.NoOverwrite); - command.Add(this.Progress); - } - - public ExportOptions Parse(ParseResult parseResult) => new() - { - Rpf = this._rpfOpts.Parse(parseResult), - OutputPath = parseResult.GetValue(this.Output)?.FullName ?? Directory.GetCurrentDirectory(), - DryRun = parseResult.GetValue(this.DryRun), - NoOverwrite = parseResult.GetValue(this.NoOverwrite), - Progress = parseResult.GetValue(this.Progress), - }; -} - internal static class ExportHandler { public static Command CreateCommand(CancellationToken cancellationToken = default) diff --git a/CodeWalker.Cli/Handlers/ExportTextHandler.cs b/CodeWalker.Cli/Handlers/ExportTextHandler.cs index a2abb8902..2a6f27f0e 100644 --- a/CodeWalker.Cli/Handlers/ExportTextHandler.cs +++ b/CodeWalker.Cli/Handlers/ExportTextHandler.cs @@ -3,6 +3,7 @@ using System.Text; using System.Threading; +using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; namespace CodeWalker.Cli.Handlers; @@ -13,21 +14,49 @@ internal static class ExportTextHandler public static Command CreateCommand(CancellationToken cancellationToken = default) { - ExportCommandOptions exportOpts = new(); + Option rpfOpt = CliOptions.Rpf(); + Option exeOpt = CliOptions.Exe(); + Option gen9Opt = CliOptions.Gen9(); + Option filterOpt = CliOptions.Filter(); + Option recursiveOpt = CliOptions.Recursive(); + Option verboseOpt = CliOptions.Verbose(); + Option jsonOpt = CliOptions.Json(); + Option siOpt = CliOptions.Si(); + Option threadsOpt = CliOptions.Threads(); + Option outputOpt = CliOptions.OutputDir(); + Option dryRunOpt = CliOptions.DryRun(); + Option noOverwriteOpt = CliOptions.NoOverwrite(); + Option progressOpt = CliOptions.Progress(); - Command command = new("text", "Export .gxt2 localization files to plain text"); - exportOpts.AddTo(command); + Command command = new("text", "Export .gxt2 localization files to plain text") + { + rpfOpt, exeOpt, gen9Opt, filterOpt, recursiveOpt, + verboseOpt, jsonOpt, siOpt, threadsOpt, + outputOpt, dryRunOpt, noOverwriteOpt, progressOpt, + }; command.Aliases.Add("g"); command.Aliases.Add("gxt2"); command.SetAction(parseResult => { - ExportOptions options = exportOpts.Parse(parseResult); - if (options.Rpf.Filters.Length == 0) + string[] filters = Filter.Normalize(parseResult.GetValue(filterOpt)); + ExportOptions options = new() { - options = options with { Rpf = options.Rpf with { Filters = DefaultFilters } }; - } - return ExportService.Execute(options, "txt", "Text", ProcessFile, cancellationToken); + RpfPath = parseResult.GetValue(rpfOpt)?.FullName ?? "", + ExePath = parseResult.GetRequiredValue(exeOpt).FullName, + Gen9 = parseResult.GetValue(gen9Opt), + Filters = filters.Length == 0 ? DefaultFilters : filters, + Verbose = parseResult.GetValue(verboseOpt), + Json = parseResult.GetValue(jsonOpt), + Recursive = parseResult.GetValue(recursiveOpt), + Threads = parseResult.GetValue(threadsOpt), + SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC, + OutputPath = parseResult.GetValue(outputOpt)?.FullName ?? Directory.GetCurrentDirectory(), + DryRun = parseResult.GetValue(dryRunOpt), + NoOverwrite = parseResult.GetValue(noOverwriteOpt), + Progress = parseResult.GetValue(progressOpt), + }; + return ExportPipeline.Execute(options, "txt", "Text", ProcessFile, cancellationToken); }); return command; diff --git a/CodeWalker.Cli/Handlers/ExportTexturesHandler.cs b/CodeWalker.Cli/Handlers/ExportTexturesHandler.cs index 607594186..45a6debc6 100644 --- a/CodeWalker.Cli/Handlers/ExportTexturesHandler.cs +++ b/CodeWalker.Cli/Handlers/ExportTexturesHandler.cs @@ -2,6 +2,7 @@ using System.IO; using System.Threading; +using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; using CodeWalker.Utils; @@ -13,21 +14,49 @@ internal static class ExportTexturesHandler public static Command CreateCommand(CancellationToken cancellationToken = default) { - ExportCommandOptions exportOpts = new(); + Option rpfOpt = CliOptions.Rpf(); + Option exeOpt = CliOptions.Exe(); + Option gen9Opt = CliOptions.Gen9(); + Option filterOpt = CliOptions.Filter(); + Option recursiveOpt = CliOptions.Recursive(); + Option verboseOpt = CliOptions.Verbose(); + Option jsonOpt = CliOptions.Json(); + Option siOpt = CliOptions.Si(); + Option threadsOpt = CliOptions.Threads(); + Option outputOpt = CliOptions.OutputDir(); + Option dryRunOpt = CliOptions.DryRun(); + Option noOverwriteOpt = CliOptions.NoOverwrite(); + Option progressOpt = CliOptions.Progress(); - Command command = new("textures", "Export .ytd texture dictionaries to DDS files"); - exportOpts.AddTo(command); + Command command = new("textures", "Export .ytd texture dictionaries to DDS files") + { + rpfOpt, exeOpt, gen9Opt, filterOpt, recursiveOpt, + verboseOpt, jsonOpt, siOpt, threadsOpt, + outputOpt, dryRunOpt, noOverwriteOpt, progressOpt, + }; command.Aliases.Add("t"); command.Aliases.Add("ytd"); command.SetAction(parseResult => { - ExportOptions options = exportOpts.Parse(parseResult); - if (options.Rpf.Filters.Length == 0) + string[] filters = Filter.Normalize(parseResult.GetValue(filterOpt)); + ExportOptions options = new() { - options = options with { Rpf = options.Rpf with { Filters = DefaultFilters } }; - } - return ExportService.Execute(options, "dds", "Texture", ProcessFile, cancellationToken); + RpfPath = parseResult.GetValue(rpfOpt)?.FullName ?? "", + ExePath = parseResult.GetRequiredValue(exeOpt).FullName, + Gen9 = parseResult.GetValue(gen9Opt), + Filters = filters.Length == 0 ? DefaultFilters : filters, + Verbose = parseResult.GetValue(verboseOpt), + Json = parseResult.GetValue(jsonOpt), + Recursive = parseResult.GetValue(recursiveOpt), + Threads = parseResult.GetValue(threadsOpt), + SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC, + OutputPath = parseResult.GetValue(outputOpt)?.FullName ?? Directory.GetCurrentDirectory(), + DryRun = parseResult.GetValue(dryRunOpt), + NoOverwrite = parseResult.GetValue(noOverwriteOpt), + Progress = parseResult.GetValue(progressOpt), + }; + return ExportPipeline.Execute(options, "dds", "Texture", ProcessFile, cancellationToken); }); return command; diff --git a/CodeWalker.Cli/Handlers/ExportXmlHandler.cs b/CodeWalker.Cli/Handlers/ExportXmlHandler.cs index 6e6995325..9f1e485c4 100644 --- a/CodeWalker.Cli/Handlers/ExportXmlHandler.cs +++ b/CodeWalker.Cli/Handlers/ExportXmlHandler.cs @@ -3,6 +3,7 @@ using System.Text; using System.Threading; +using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; namespace CodeWalker.Cli.Handlers; @@ -11,16 +12,47 @@ internal static class ExportXmlHandler { public static Command CreateCommand(CancellationToken cancellationToken = default) { - ExportCommandOptions exportOpts = new(); + Option rpfOpt = CliOptions.Rpf(); + Option exeOpt = CliOptions.Exe(); + Option gen9Opt = CliOptions.Gen9(); + Option filterOpt = CliOptions.Filter(); + Option recursiveOpt = CliOptions.Recursive(); + Option verboseOpt = CliOptions.Verbose(); + Option jsonOpt = CliOptions.Json(); + Option siOpt = CliOptions.Si(); + Option threadsOpt = CliOptions.Threads(); + Option outputOpt = CliOptions.OutputDir(); + Option dryRunOpt = CliOptions.DryRun(); + Option noOverwriteOpt = CliOptions.NoOverwrite(); + Option progressOpt = CliOptions.Progress(); - Command command = new("xml", "Export binary game files to XML"); - exportOpts.AddTo(command); + Command command = new("xml", "Export binary game files to XML") + { + rpfOpt, exeOpt, gen9Opt, filterOpt, recursiveOpt, + verboseOpt, jsonOpt, siOpt, threadsOpt, + outputOpt, dryRunOpt, noOverwriteOpt, progressOpt, + }; command.Aliases.Add("x"); command.SetAction(parseResult => { - ExportOptions options = exportOpts.Parse(parseResult); - return ExportService.Execute(options, "xml", "XML", ProcessFile, cancellationToken); + ExportOptions options = new() + { + RpfPath = parseResult.GetValue(rpfOpt)?.FullName ?? "", + ExePath = parseResult.GetRequiredValue(exeOpt).FullName, + Gen9 = parseResult.GetValue(gen9Opt), + Filters = Filter.Normalize(parseResult.GetValue(filterOpt)), + Verbose = parseResult.GetValue(verboseOpt), + Json = parseResult.GetValue(jsonOpt), + Recursive = parseResult.GetValue(recursiveOpt), + Threads = parseResult.GetValue(threadsOpt), + SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC, + OutputPath = parseResult.GetValue(outputOpt)?.FullName ?? Directory.GetCurrentDirectory(), + DryRun = parseResult.GetValue(dryRunOpt), + NoOverwrite = parseResult.GetValue(noOverwriteOpt), + Progress = parseResult.GetValue(progressOpt), + }; + return ExportPipeline.Execute(options, "xml", "XML", ProcessFile, cancellationToken); }); return command; diff --git a/CodeWalker.Cli/Handlers/ExtractHandler.cs b/CodeWalker.Cli/Handlers/ExtractHandler.cs index b3c5e39bf..61e2ac286 100644 --- a/CodeWalker.Cli/Handlers/ExtractHandler.cs +++ b/CodeWalker.Cli/Handlers/ExtractHandler.cs @@ -13,7 +13,15 @@ namespace CodeWalker.Cli.Handlers; internal sealed record ExtractOptions { - public required RpfOptions Rpf { get; init; } + public required string RpfPath { get; init; } + public required string ExePath { get; init; } + public required bool Gen9 { get; init; } + public required string[] Filters { get; init; } + public required bool Verbose { get; init; } + public required bool Json { get; init; } + public required bool Recursive { get; init; } + public required int Threads { get; init; } + public required SizeFormat SizeFormat { get; init; } public required string? OutputPath { get; init; } public required bool DryRun { get; init; } public required bool NoOverwrite { get; init; } @@ -24,43 +32,51 @@ internal static class ExtractHandler { public static Command CreateCommand(CancellationToken cancellationToken = default) { - RpfCommandOptions rpfOpts = new(); - Option outputOption = new("--output", "-o") - { - Description = "Output directory", - DefaultValueFactory = _ => new DirectoryInfo(Directory.GetCurrentDirectory()), - }; - - Option dryRunOption = new("--dry-run", "-n") - { - Description = "Show what would be extracted without actually extracting", - }; - - Option noOverwriteOption = new("--no-overwrite") - { - Description = "Skip existing files instead of overwriting", - }; - - Option progressOption = new("--progress", "-P") - { - Description = "Show progress bar during extraction", - }; + Option rpfOpt = CliOptions.Rpf(); + Option exeOpt = CliOptions.Exe(); + Option gen9Opt = CliOptions.Gen9(); + Option filterOpt = CliOptions.Filter(); + Option recursiveOpt = CliOptions.Recursive(); + Option verboseOpt = CliOptions.Verbose(); + Option jsonOpt = CliOptions.Json(); + Option siOpt = CliOptions.Si(); + Option threadsOpt = CliOptions.Threads(); + Option outputOption = CliOptions.OutputDir(); + Option dryRunOption = CliOptions.DryRun(); + Option noOverwriteOption = CliOptions.NoOverwrite(); + Option progressOption = CliOptions.Progress(); Command command = new("extract", "Extract files from an RPF archive") { + rpfOpt, + exeOpt, + gen9Opt, + filterOpt, + recursiveOpt, + verboseOpt, + jsonOpt, + siOpt, + threadsOpt, outputOption, dryRunOption, noOverwriteOption, progressOption, }; - rpfOpts.AddTo(command); command.Aliases.Add("x"); command.SetAction(parseResult => { ExtractOptions options = new() { - Rpf = rpfOpts.Parse(parseResult), + RpfPath = parseResult.GetValue(rpfOpt)?.FullName ?? "", + ExePath = parseResult.GetRequiredValue(exeOpt).FullName, + Gen9 = parseResult.GetValue(gen9Opt), + Filters = Filter.Normalize(parseResult.GetValue(filterOpt)), + Verbose = parseResult.GetValue(verboseOpt), + Json = parseResult.GetValue(jsonOpt), + Recursive = parseResult.GetValue(recursiveOpt), + Threads = parseResult.GetValue(threadsOpt), + SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC, OutputPath = parseResult.GetValue(outputOption)?.FullName, DryRun = parseResult.GetValue(dryRunOption), NoOverwrite = parseResult.GetValue(noOverwriteOption), @@ -78,7 +94,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => new() { Success = false, - RpfFile = options.Rpf.RpfPath, + RpfFile = options.RpfPath, OutputDir = options.OutputPath ?? Directory.GetCurrentDirectory(), TotalFiles = 0, Extracted = 0, @@ -89,28 +105,28 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => ErrorMessages = errorMessages, }; - string? initError = RpfService.ValidateAndLoadKeys( - options.Rpf.RpfPath, - options.Rpf.ExePath, - options.Rpf.Gen9, - options.Rpf.Json + string? initError = RpfHelper.ValidateAndLoadKeys( + options.RpfPath, + options.ExePath, + options.Gen9, + options.Json ); if (initError != null) { - return RpfService.ReportError(initError, options.Rpf.Json, ErrorResult([])); + return Output.ReportError(initError, options.Json, ErrorResult([])); } List scanErrors = []; try { - RpfFile rpf = RpfService.OpenRpf( - options.Rpf.RpfPath, - options.Rpf.Verbose, - options.Rpf.Json, + RpfFile rpf = RpfHelper.OpenRpf( + options.RpfPath, + options.Verbose, + options.Json, scanErrors ); - if (!options.Rpf.Json && options.DryRun) + if (!options.Json && options.DryRun) { Console.Error.WriteLine("Dry run mode - no files will be extracted"); } @@ -123,14 +139,14 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => } // Collect files first for progress bar - List<(RpfFile rpf, RpfFileEntry entry)> filesToExtract = RpfService.CollectFiles( + List<(RpfFile rpf, RpfFileEntry entry)> filesToExtract = RpfHelper.CollectFiles( rpf, - options.Rpf.Filters, - options.Rpf.Recursive + options.Filters, + options.Recursive ); // Count non-RPF files that were excluded by filters - int totalNonRpfFiles = RpfService.CountNonRpfFiles(rpf, options.Rpf.Recursive); + int totalNonRpfFiles = RpfHelper.CountNonRpfFiles(rpf, options.Recursive); int skipped = totalNonRpfFiles - filesToExtract.Count; int overwriteSkipped = 0; @@ -143,14 +159,14 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => using ( ProgressBar progress = new( filesToExtract.Count, - options.Progress && !options.Rpf.Json + options.Progress && !options.Json ) ) { _ = Parallel.For( 0, filesToExtract.Count, - new ParallelOptions { MaxDegreeOfParallelism = options.Rpf.Threads, CancellationToken = cancellationToken }, + new ParallelOptions { MaxDegreeOfParallelism = options.Threads, CancellationToken = cancellationToken }, i => { (RpfFile sourceRpf, RpfFileEntry fileEntry) = filesToExtract[i]; @@ -171,14 +187,14 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => Path = fileEntry.Path, Name = fileEntry.Name, Size = size, - SizeFormatted = options.Rpf.SizeFormat.ToFormattedString(size), - Type = RpfService.GetFileType(fileEntry), + SizeFormatted = options.SizeFormat.ToFormattedString(size), + Type = RpfHelper.GetFileType(fileEntry), Extension = ext, }; if (options.DryRun) { - if (options.Rpf.Verbose && !options.Rpf.Json) + if (options.Verbose && !options.Json) { lock (consoleLock) { @@ -190,7 +206,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => else if (options.NoOverwrite && File.Exists(outputPath)) { _ = Interlocked.Increment(ref overwriteSkipped); - if (options.Rpf.Verbose && !options.Rpf.Json && !options.Progress) + if (options.Verbose && !options.Json && !options.Progress) { lock (consoleLock) { @@ -208,7 +224,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => _ = Directory.CreateDirectory(fileDir); } - if (options.Rpf.Verbose && !options.Rpf.Json && !options.Progress) + if (options.Verbose && !options.Json && !options.Progress) { lock (consoleLock) { @@ -227,7 +243,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => } else { - if (options.Rpf.Verbose && !options.Rpf.Json) + if (options.Verbose && !options.Json) { lock (consoleLock) { @@ -247,7 +263,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => } catch (Exception ex) { - if (!options.Rpf.Json) + if (!options.Json) { lock (consoleLock) { @@ -292,7 +308,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => Json.ExtractResult result = new() { Success = errors == 0 && scanErrors.Count == 0, - RpfFile = options.Rpf.RpfPath, + RpfFile = options.RpfPath, OutputDir = options.OutputPath ?? Directory.GetCurrentDirectory(), TotalFiles = totalNonRpfFiles, Extracted = extracted, @@ -303,10 +319,10 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => ErrorMessages = [.. errorMessages], }; - if (options.Rpf.Json) + if (options.Json) { Console.WriteLine( - JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + JsonSerializer.Serialize(result, Output.JsonSerializerOptions) ); } else @@ -323,11 +339,11 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => catch (OperationCanceledException) { throw; } catch (Exception ex) { - return RpfService.ReportError( + return Output.ReportError( ex.Message, - options.Rpf.Json, + options.Json, ErrorResult([.. scanErrors]), - options.Rpf.Verbose ? ex.StackTrace : null + options.Verbose ? ex.StackTrace : null ); } } diff --git a/CodeWalker.Cli/Handlers/Gen9Handler.cs b/CodeWalker.Cli/Handlers/Gen9Handler.cs index 55ed532c9..3596e563e 100644 --- a/CodeWalker.Cli/Handlers/Gen9Handler.cs +++ b/CodeWalker.Cli/Handlers/Gen9Handler.cs @@ -17,7 +17,11 @@ internal sealed record Gen9Options { public required string InputPath { get; init; } public required string OutputPath { get; init; } - public required CommonOptions Common { get; init; } + public required string ExePath { get; init; } + public required bool Verbose { get; init; } + public required bool Json { get; init; } + public required SizeFormat SizeFormat { get; init; } + public required int Threads { get; init; } public required bool NoRecurse { get; init; } public required bool NoOverwrite { get; init; } public required bool SkipUnconverted { get; init; } @@ -28,7 +32,12 @@ internal static class Gen9Handler { public static Command CreateCommand(CancellationToken cancellationToken = default) { - CommonCommandOptions commonOpts = new(); + Option exeOpt = CliOptions.Exe(); + Option verboseOpt = CliOptions.Verbose(); + Option jsonOpt = CliOptions.Json(); + Option siOpt = CliOptions.Si(); + Option threadsOpt = CliOptions.Threads(); + Option inputOption = new("--input", "-i") { Description = "Input folder containing files to convert", @@ -61,6 +70,7 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul Description = "Show progress bar", }; + Command command = new("gen9", "Convert files to enhanced (Gen9) format") { inputOption, @@ -68,9 +78,14 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul noRecurseOption, noOverwriteOption, skipUnconvertedOption, + progressOption, + exeOpt, + verboseOpt, + jsonOpt, + siOpt, + threadsOpt }; - commonOpts.AddTo(command); command.Aliases.Add("g"); command.SetAction(parseResult => @@ -79,7 +94,11 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul { InputPath = parseResult.GetRequiredValue(inputOption).FullName, OutputPath = parseResult.GetRequiredValue(outputOption).FullName, - Common = commonOpts.Parse(parseResult), + ExePath = parseResult.GetRequiredValue(exeOpt).FullName, + Verbose = parseResult.GetValue(verboseOpt), + Json = parseResult.GetValue(jsonOpt), + SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC, + Threads = parseResult.GetValue(threadsOpt), NoRecurse = parseResult.GetValue(noRecurseOption), NoOverwrite = parseResult.GetValue(noOverwriteOption), SkipUnconverted = parseResult.GetValue(skipUnconvertedOption), @@ -110,9 +129,9 @@ Json.Gen9Result ErrorResult(string[] errorMessages) => if (!Directory.Exists(options.InputPath)) { - return RpfService.ReportError( + return Output.ReportError( $"Input folder not found: {options.InputPath}", - options.Common.Json, + options.Json, ErrorResult([]) ); } @@ -125,21 +144,21 @@ Json.Gen9Result ErrorResult(string[] errorMessages) => ) ) { - return RpfService.ReportError( + return Output.ReportError( "Input folder and Output folder must be different.", - options.Common.Json, + options.Json, ErrorResult([]) ); } - string? exeError = RpfService.ValidateExeAndLoadKeys( - options.Common.ExePath, + string? exeError = RpfHelper.ValidateExeAndLoadKeys( + options.ExePath, true, - options.Common.Json + options.Json ); if (exeError != null) { - return RpfService.ReportError(exeError, options.Common.Json, ErrorResult([])); + return Output.ReportError(exeError, options.Json, ErrorResult([])); } try @@ -174,7 +193,7 @@ Json.Gen9Result ErrorResult(string[] errorMessages) => int totalFileCount = filePaths.Count + rpfPaths.Count; - if (!options.Common.Json) + if (!options.Json) { Console.Error.WriteLine($"Found {totalFileCount} files in {options.InputPath}"); } @@ -190,7 +209,7 @@ Json.Gen9Result ErrorResult(string[] errorMessages) => using ( ProgressBar progress = new( totalFileCount, - options.Progress && !options.Common.Json + options.Progress && !options.Json ) ) { @@ -204,7 +223,7 @@ Json.Gen9Result ErrorResult(string[] errorMessages) => _ = Parallel.For( 0, filePaths.Count, - new ParallelOptions { MaxDegreeOfParallelism = options.Common.Threads, CancellationToken = cancellationToken }, + new ParallelOptions { MaxDegreeOfParallelism = options.Threads, CancellationToken = cancellationToken }, i => { string path = filePaths[i]; @@ -224,7 +243,7 @@ Json.Gen9Result ErrorResult(string[] errorMessages) => }, null ); - if (options.Common.Verbose && !options.Common.Json) + if (options.Verbose && !options.Json) { lock (consoleLock) { @@ -250,7 +269,7 @@ Json.Gen9Result ErrorResult(string[] errorMessages) => ext, msg => { - if (options.Common.Verbose && !options.Common.Json) + if (options.Verbose && !options.Json) { lock (consoleLock) { @@ -313,7 +332,7 @@ out bool wasConverted }, errorMsg ); - if (!options.Common.Json) + if (!options.Json) { lock (consoleLock) { @@ -368,7 +387,7 @@ out bool wasConverted Message = "Output file already exists", } ); - if (options.Common.Verbose && !options.Common.Json) + if (options.Verbose && !options.Json) { Console.Error.WriteLine($"{relPath} - skipped (exists)"); } @@ -408,7 +427,7 @@ ref errors Message = ex.Message, } ); - if (!options.Common.Json) + if (!options.Json) { Console.Error.WriteLine($"Error: {errorMsg}"); } @@ -431,10 +450,10 @@ ref errors ErrorMessages = [.. errorMessages], }; - if (options.Common.Json) + if (options.Json) { Console.WriteLine( - JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + JsonSerializer.Serialize(result, Output.JsonSerializerOptions) ); } else @@ -455,11 +474,11 @@ ref errors catch (OperationCanceledException) { throw; } catch (Exception ex) { - return RpfService.ReportError( + return Output.ReportError( ex.Message, - options.Common.Json, + options.Json, ErrorResult([]), - options.Common.Verbose ? ex.StackTrace : null + options.Verbose ? ex.StackTrace : null ); } } @@ -475,7 +494,7 @@ private static void ProcessRpfFile( ref int errors ) { - if (options.Common.Verbose && !options.Common.Json) + if (options.Verbose && !options.Json) { Console.Error.WriteLine($"{relPath} - Converting RPF contents..."); } @@ -486,12 +505,12 @@ ref int errors rpf.ScanStructure( status => { - if (options.Common.Verbose && !options.Common.Json) + if (options.Verbose && !options.Json) Console.Error.WriteLine(status); }, error => { - if (!options.Common.Json) + if (!options.Json) Console.Error.WriteLine($"Error: {error}"); errorMessages.Add(error); } @@ -562,7 +581,7 @@ ref int errors type, msg => { - if (options.Common.Verbose && !options.Common.Json) + if (options.Verbose && !options.Json) Console.Error.WriteLine(msg); }, rfe.Path, @@ -594,7 +613,7 @@ out bool wasConverted if (changed) { - if (options.Common.Verbose && !options.Common.Json) + if (options.Verbose && !options.Json) { Console.Error.WriteLine($"{currentRpf.Path} - Defragmenting"); } diff --git a/CodeWalker.Cli/Handlers/HashHandler.cs b/CodeWalker.Cli/Handlers/HashHandler.cs index aa77800cf..78914fd64 100644 --- a/CodeWalker.Cli/Handlers/HashHandler.cs +++ b/CodeWalker.Cli/Handlers/HashHandler.cs @@ -5,6 +5,7 @@ using System.Text.Json; using System.Threading; +using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; namespace CodeWalker.Cli.Handlers; @@ -90,7 +91,7 @@ public static int Execute(HashOptions options, CancellationToken cancellationTok } catch (Exception ex) { - return RpfService.ReportError( + return Output.ReportError( ex.Message, options.Json, ErrorResult([]) @@ -170,7 +171,7 @@ internal static void PrintJsonHashes(Json.HashEntry[] hashes) Hashes = hashes, ErrorMessages = [] }; - Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); + Console.WriteLine(JsonSerializer.Serialize(result, Output.JsonSerializerOptions)); } /// diff --git a/CodeWalker.Cli/Handlers/InspectHandler.cs b/CodeWalker.Cli/Handlers/InspectHandler.cs index ef7be2eb2..044e3561b 100644 --- a/CodeWalker.Cli/Handlers/InspectHandler.cs +++ b/CodeWalker.Cli/Handlers/InspectHandler.cs @@ -13,11 +13,32 @@ namespace CodeWalker.Cli.Handlers; +internal sealed record InspectOptions +{ + public required string RpfPath { get; init; } + public required string ExePath { get; init; } + public required bool Gen9 { get; init; } + public required string[] Filters { get; init; } + public required bool Verbose { get; init; } + public required bool Json { get; init; } + public required bool Recursive { get; init; } + public required SizeFormat SizeFormat { get; init; } + public required string FilePath { get; init; } +} + internal static class InspectHandler { public static Command CreateCommand(CancellationToken cancellationToken = default) { - RpfCommandOptions rpfOpts = new(); + Option rpfOpt = CliOptions.Rpf(); + Option exeOpt = CliOptions.Exe(); + Option gen9Opt = CliOptions.Gen9(); + Option filterOpt = CliOptions.Filter(); + Option recursiveOpt = CliOptions.Recursive(); + Option verboseOpt = CliOptions.Verbose(); + Option jsonOpt = CliOptions.Json(); + Option siOpt = CliOptions.Si(); + Argument pathArg = new("path") { Description = "Path of the file within the RPF archive", @@ -29,18 +50,38 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul ) { pathArg, + rpfOpt, + exeOpt, + gen9Opt, + filterOpt, + recursiveOpt, + verboseOpt, + jsonOpt, + siOpt, }; - rpfOpts.AddTo(command); command.Aliases.Add("i"); command.SetAction(parseResult => - Execute(rpfOpts.Parse(parseResult), parseResult.GetRequiredValue(pathArg), cancellationToken) - ); + { + InspectOptions options = new() + { + RpfPath = parseResult.GetRequiredValue(rpfOpt).FullName, + ExePath = parseResult.GetRequiredValue(exeOpt).FullName, + Gen9 = parseResult.GetValue(gen9Opt), + Filters = Filter.Normalize(parseResult.GetValue(filterOpt)), + Verbose = parseResult.GetValue(verboseOpt), + Json = parseResult.GetValue(jsonOpt), + Recursive = parseResult.GetValue(recursiveOpt), + SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC, + FilePath = parseResult.GetRequiredValue(pathArg), + }; + return Execute(options, cancellationToken); + }); return command; } - public static int Execute(RpfOptions options, string filePath, CancellationToken cancellationToken = default) + public static int Execute(InspectOptions options, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -49,7 +90,7 @@ Json.InspectResult ErrorResult(string[] errorMessages) => { Success = false, RpfFile = options.RpfPath, - Path = filePath, + Path = options.FilePath, Name = "", Size = 0, SizeFormatted = "0 B", @@ -60,7 +101,7 @@ Json.InspectResult ErrorResult(string[] errorMessages) => ErrorMessages = errorMessages, }; - string? initError = RpfService.ValidateAndLoadKeys( + string? initError = RpfHelper.ValidateAndLoadKeys( options.RpfPath, options.ExePath, options.Gen9, @@ -68,13 +109,13 @@ Json.InspectResult ErrorResult(string[] errorMessages) => ); if (initError != null) { - return RpfService.ReportError(initError, options.Json, ErrorResult([])); + return Output.ReportError(initError, options.Json, ErrorResult([])); } List scanErrors = []; try { - RpfFile rpf = RpfService.OpenRpf( + RpfFile rpf = RpfHelper.OpenRpf( options.RpfPath, options.Verbose, options.Json, @@ -87,13 +128,13 @@ Json.InspectResult ErrorResult(string[] errorMessages) => } // Find entry by normalized path - string normalizedPath = filePath.Replace('\\', '/'); + string normalizedPath = options.FilePath.Replace('\\', '/'); RpfFileEntry? found = FindEntry(rpf, normalizedPath, options.Recursive); if (found == null) { - return RpfService.ReportError( - $"File not found in archive: {filePath}", + return Output.ReportError( + $"File not found in archive: {options.FilePath}", options.Json, ErrorResult([.. scanErrors]) ); @@ -101,7 +142,7 @@ Json.InspectResult ErrorResult(string[] errorMessages) => long size = found.GetFileSize(); string ext = Path.GetExtension(found.Name).ToLowerInvariant(); - string fileType = RpfService.GetFileType(found); + string fileType = RpfHelper.GetFileType(found); // Extract type-specific metadata int? resourceVersion = null; @@ -146,7 +187,7 @@ Json.InspectResult ErrorResult(string[] errorMessages) => if (options.Json) { Console.WriteLine( - JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + JsonSerializer.Serialize(result, Output.JsonSerializerOptions) ); } else @@ -159,7 +200,7 @@ Json.InspectResult ErrorResult(string[] errorMessages) => catch (OperationCanceledException) { throw; } catch (Exception ex) { - return RpfService.ReportError( + return Output.ReportError( ex.Message, options.Json, ErrorResult([.. scanErrors]), @@ -458,7 +499,7 @@ private static void AddLod(List lods, string level, DrawableModel[ internal static string FormatVector3(Vector3 v) => $"{v.X:F2}, {v.Y:F2}, {v.Z:F2}"; - private static void PrintTextResult(Json.InspectResult result, RpfOptions options) + private static void PrintTextResult(Json.InspectResult result, InspectOptions options) { Console.WriteLine($"Path: {result.Path}"); Console.WriteLine($"Name: {result.Name}"); diff --git a/CodeWalker.Cli/Handlers/ListHandler.cs b/CodeWalker.Cli/Handlers/ListHandler.cs index c0a6ef780..be659ee1a 100644 --- a/CodeWalker.Cli/Handlers/ListHandler.cs +++ b/CodeWalker.Cli/Handlers/ListHandler.cs @@ -10,24 +10,66 @@ namespace CodeWalker.Cli.Handlers; +internal sealed record ListOptions +{ + public required string RpfPath { get; init; } + public required string ExePath { get; init; } + public required bool Gen9 { get; init; } + public required string[] Filters { get; init; } + public required bool Verbose { get; init; } + public required bool Json { get; init; } + public required bool Recursive { get; init; } + public required SizeFormat SizeFormat { get; init; } +} + internal static class ListHandler { public static Command CreateCommand(CancellationToken cancellationToken = default) { - RpfCommandOptions rpfOpts = new(); - - Command command = new("list", "List contents of an RPF archive"); - rpfOpts.AddTo(command, includeThreads: false); + Option rpfOpt = CliOptions.Rpf(); + Option exeOpt = CliOptions.Exe(); + Option gen9Opt = CliOptions.Gen9(); + Option filterOpt = CliOptions.Filter(); + Option recursiveOpt = CliOptions.Recursive(); + Option verboseOpt = CliOptions.Verbose(); + Option jsonOpt = CliOptions.Json(); + Option siOpt = CliOptions.Si(); + + Command command = new("list", "List contents of an RPF archive") + { + rpfOpt, + exeOpt, + gen9Opt, + filterOpt, + recursiveOpt, + verboseOpt, + jsonOpt, + siOpt, + }; command.Aliases.Add("l"); - command.SetAction(parseResult => Execute(rpfOpts.Parse(parseResult), cancellationToken)); + command.SetAction(parseResult => + { + ListOptions options = new() + { + RpfPath = parseResult.GetValue(rpfOpt)?.FullName ?? "", + ExePath = parseResult.GetRequiredValue(exeOpt).FullName, + Gen9 = parseResult.GetValue(gen9Opt), + Filters = Filter.Normalize(parseResult.GetValue(filterOpt)), + Verbose = parseResult.GetValue(verboseOpt), + Json = parseResult.GetValue(jsonOpt), + Recursive = parseResult.GetValue(recursiveOpt), + SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC, + }; + return Execute(options, cancellationToken); + }); return command; } - public static int Execute(RpfOptions options, CancellationToken cancellationToken = default) + public static int Execute(ListOptions options, CancellationToken cancellationToken = default) { - string? initError = RpfService.ValidateAndLoadKeys( + string? initError = RpfHelper.ValidateAndLoadKeys( options.RpfPath, options.ExePath, options.Gen9, @@ -35,7 +77,7 @@ public static int Execute(RpfOptions options, CancellationToken cancellationToke ); if (initError != null) { - return RpfService.ReportError( + return Output.ReportError( initError, options.Json, ErrorResult([], options) @@ -45,7 +87,7 @@ public static int Execute(RpfOptions options, CancellationToken cancellationToke List scanErrors = []; try { - RpfFile rpf = RpfService.OpenRpf( + RpfFile rpf = RpfHelper.OpenRpf( options.RpfPath, options.Verbose, options.Json, @@ -55,7 +97,7 @@ public static int Execute(RpfOptions options, CancellationToken cancellationToke if (!options.Json) Console.Error.WriteLine(); - List<(RpfFile rpf, RpfFileEntry entry)> entries = RpfService.CollectFiles( + List<(RpfFile rpf, RpfFileEntry entry)> entries = RpfHelper.CollectFiles( rpf, options.Filters, options.Recursive @@ -77,7 +119,7 @@ public static int Execute(RpfOptions options, CancellationToken cancellationToke } catch (Exception ex) { - return RpfService.ReportError( + return Output.ReportError( ex.Message, options.Json, ErrorResult([.. scanErrors], options), @@ -86,7 +128,7 @@ public static int Execute(RpfOptions options, CancellationToken cancellationToke } } - internal static Json.ListResult ErrorResult(string[] errorMessages, RpfOptions options) => + internal static Json.ListResult ErrorResult(string[] errorMessages, ListOptions options) => new() { Success = false, @@ -103,7 +145,7 @@ internal static Json.ListResult CollectList( List<(RpfFile rpf, RpfFileEntry entry)> entries, RpfFile rpf, List scanErrors, - RpfOptions options, + ListOptions options, CancellationToken cancellationToken = default) { long totalSize = 0; @@ -123,7 +165,7 @@ internal static Json.ListResult CollectList( Name = fileEntry.Name, Size = size, SizeFormatted = options.SizeFormat.ToFormattedString(size), - Type = RpfService.GetFileType(fileEntry), + Type = RpfHelper.GetFileType(fileEntry), Extension = ext, } ); @@ -143,11 +185,11 @@ internal static Json.ListResult CollectList( } internal static void PrintJsonList(Json.ListResult result) => - Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); + Console.WriteLine(JsonSerializer.Serialize(result, Output.JsonSerializerOptions)); internal static void PrintList( Json.ListResult result, - RpfOptions options, + ListOptions options, CancellationToken cancellationToken = default) { foreach (Json.FileEntry file in result.Files) diff --git a/CodeWalker.Cli/Handlers/PackHandler.cs b/CodeWalker.Cli/Handlers/PackHandler.cs index 6aded0ac9..eaa42e1aa 100644 --- a/CodeWalker.Cli/Handlers/PackHandler.cs +++ b/CodeWalker.Cli/Handlers/PackHandler.cs @@ -14,7 +14,10 @@ internal sealed record PackOptions { public required string InputPath { get; init; } public required string OutputPath { get; init; } - public required CommonOptions Common { get; init; } + public required string ExePath { get; init; } + public required bool Verbose { get; init; } + public required bool Json { get; init; } + public required SizeFormat SizeFormat { get; init; } public required bool Gen9 { get; init; } public required bool Force { get; init; } public required bool Progress { get; init; } @@ -24,7 +27,11 @@ internal static class PackHandler { public static Command CreateCommand(CancellationToken cancellationToken = default) { - CommonCommandOptions commonOpts = new(); + Option exeOpt = CliOptions.Exe(); + Option verboseOpt = CliOptions.Verbose(); + Option jsonOpt = CliOptions.Json(); + Option siOpt = CliOptions.Si(); + Option inputOption = new("--input", "-i") { Description = "Source directory of loose files to pack", @@ -52,15 +59,21 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul Description = "Show progress bar", }; + Command command = new("pack", "Create an RPF archive from a directory of loose files") { inputOption, outputOption, gen9Option, forceOption, + progressOption, + exeOpt, + verboseOpt, + jsonOpt, + siOpt }; - commonOpts.AddTo(command, includeThreads: false); + command.Aliases.Add("p"); command.SetAction(parseResult => @@ -69,7 +82,10 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul { InputPath = parseResult.GetRequiredValue(inputOption).FullName, OutputPath = parseResult.GetRequiredValue(outputOption).FullName, - Common = commonOpts.Parse(parseResult), + ExePath = parseResult.GetRequiredValue(exeOpt).FullName, + Verbose = parseResult.GetValue(verboseOpt), + Json = parseResult.GetValue(jsonOpt), + SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC, Gen9 = parseResult.GetValue(gen9Option), Force = parseResult.GetValue(forceOption), Progress = parseResult.GetValue(progressOption), @@ -98,9 +114,9 @@ Json.PackResult ErrorResult(string[] errorMessages) => if (!Directory.Exists(options.InputPath)) { - return RpfService.ReportError( + return Output.ReportError( $"Input directory not found: {options.InputPath}", - options.Common.Json, + options.Json, ErrorResult([]) ); } @@ -109,23 +125,23 @@ Json.PackResult ErrorResult(string[] errorMessages) => { if (!options.Force) { - return RpfService.ReportError( + return Output.ReportError( $"Output file already exists: {options.OutputPath}. Use --force to overwrite.", - options.Common.Json, + options.Json, ErrorResult([]) ); } File.Delete(options.OutputPath); } - string? exeError = RpfService.ValidateExeAndLoadKeys( - options.Common.ExePath, + string? exeError = RpfHelper.ValidateExeAndLoadKeys( + options.ExePath, options.Gen9, - options.Common.Json + options.Json ); if (exeError != null) { - return RpfService.ReportError(exeError, options.Common.Json, ErrorResult([])); + return Output.ReportError(exeError, options.Json, ErrorResult([])); } bool previousGen9 = RpfManager.IsGen9; @@ -139,7 +155,7 @@ Json.PackResult ErrorResult(string[] errorMessages) => SearchOption.AllDirectories ); - if (!options.Common.Json) + if (!options.Json) { Console.Error.WriteLine( $"Packing {allFiles.Length} files from {options.InputPath}" @@ -158,7 +174,7 @@ Json.PackResult ErrorResult(string[] errorMessages) => RpfFile rpf = RpfFile.CreateNew(outputFolder, outputFileName); - if (!options.Common.Json) + if (!options.Json) { Console.Error.WriteLine($"Created RPF: {options.OutputPath}"); } @@ -172,7 +188,7 @@ Json.PackResult ErrorResult(string[] errorMessages) => using ( ProgressBar progress = new( allFiles.Length, - options.Progress && !options.Common.Json + options.Progress && !options.Json ) ) { @@ -190,13 +206,13 @@ Json.PackResult ErrorResult(string[] errorMessages) => ); } - if (!options.Common.Json) + if (!options.Json) { Console.Error.WriteLine("Defragmenting archive..."); } RpfFile.Defragment(rpf); - SizeFormat sizeFormat = options.Common.SizeFormat; + SizeFormat sizeFormat = options.SizeFormat; Json.PackResult result = new() { @@ -211,10 +227,10 @@ Json.PackResult ErrorResult(string[] errorMessages) => ErrorMessages = [.. errorMessages], }; - if (options.Common.Json) + if (options.Json) { Console.WriteLine( - JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + JsonSerializer.Serialize(result, Output.JsonSerializerOptions) ); } else @@ -230,11 +246,11 @@ Json.PackResult ErrorResult(string[] errorMessages) => catch (OperationCanceledException) { throw; } catch (Exception ex) { - return RpfService.ReportError( + return Output.ReportError( ex.Message, - options.Common.Json, + options.Json, ErrorResult([]), - options.Common.Verbose ? ex.StackTrace : null + options.Verbose ? ex.StackTrace : null ); } finally @@ -263,7 +279,7 @@ CancellationToken cancellationToken string dirName = Path.GetFileName(subDirPath); try { - if (options.Common.Verbose && !options.Common.Json) + if (options.Verbose && !options.Json) { Console.Error.WriteLine($"Creating directory: {dirName}"); } @@ -289,7 +305,7 @@ CancellationToken cancellationToken errors++; string errorMsg = $"Error creating directory {dirName}: {ex.Message}"; errorMessages.Add(errorMsg); - if (!options.Common.Json) + if (!options.Json) { Console.Error.WriteLine($"Error: {errorMsg}"); } @@ -305,7 +321,7 @@ CancellationToken cancellationToken { byte[] data = File.ReadAllBytes(filePath); - if (options.Common.Verbose && !options.Common.Json) + if (options.Verbose && !options.Json) { Console.Error.WriteLine($"Adding file: {fileName} ({data.Length} bytes)"); } @@ -320,7 +336,7 @@ CancellationToken cancellationToken errors++; string errorMsg = $"Error adding file {fileName}: {ex.Message}"; errorMessages.Add(errorMsg); - if (!options.Common.Json) + if (!options.Json) { Console.Error.WriteLine($"Error: {errorMsg}"); } diff --git a/CodeWalker.Cli/Handlers/SearchHandler.cs b/CodeWalker.Cli/Handlers/SearchHandler.cs index e257d8480..f2cd6e40e 100644 --- a/CodeWalker.Cli/Handlers/SearchHandler.cs +++ b/CodeWalker.Cli/Handlers/SearchHandler.cs @@ -10,14 +10,33 @@ namespace CodeWalker.Cli.Handlers; +internal sealed record SearchOptions +{ + public required string RpfPath { get; init; } + public required string ExePath { get; init; } + public required bool Gen9 { get; init; } + public required string[] Filters { get; init; } + public required bool Verbose { get; init; } + public required bool Json { get; init; } + public required bool Recursive { get; init; } + public required SizeFormat SizeFormat { get; init; } + public required string Pattern { get; init; } + public string? DirPath { get; init; } +} + internal static class SearchHandler { public static Command CreateCommand(CancellationToken cancellationToken = default) { - RpfCommandOptions rpfOpts = new() - { - Rpf = { Required = false } - }; + Option rpfOpt = CliOptions.Rpf(); + rpfOpt.Required = false; + Option exeOpt = CliOptions.Exe(); + Option gen9Opt = CliOptions.Gen9(); + Option filterOpt = CliOptions.Filter(); + Option recursiveOpt = CliOptions.Recursive(); + Option verboseOpt = CliOptions.Verbose(); + Option jsonOpt = CliOptions.Json(); + Option siOpt = CliOptions.Si(); Option dirOpt = new("--dir", "-D") { @@ -32,36 +51,53 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul Command command = new("search", "Search for files by name or path in an RPF archive") { patternArg, + rpfOpt, + exeOpt, + gen9Opt, + filterOpt, + recursiveOpt, + verboseOpt, + jsonOpt, + siOpt, + dirOpt, }; - rpfOpts.AddTo(command, includeThreads: false); - command.Add(dirOpt); command.Aliases.Add("s"); command.Validators.Add(result => { - bool hasRpf = result.GetValue(rpfOpts.Rpf) != null; + bool hasRpf = result.GetValue(rpfOpt) != null; bool hasDir = result.GetValue(dirOpt) != null; if (hasRpf == hasDir) result.AddError("Specify exactly one of --rpf or --dir."); }); command.SetAction(parseResult => - Execute( - rpfOpts.Parse(parseResult), - parseResult.GetRequiredValue(patternArg), - parseResult.GetValue(dirOpt)?.FullName, - cancellationToken) - ); + { + SearchOptions options = new() + { + RpfPath = parseResult.GetValue(rpfOpt)?.FullName ?? "", + ExePath = parseResult.GetRequiredValue(exeOpt).FullName, + Gen9 = parseResult.GetValue(gen9Opt), + Filters = Filter.Normalize(parseResult.GetValue(filterOpt)), + Verbose = parseResult.GetValue(verboseOpt), + Json = parseResult.GetValue(jsonOpt), + Recursive = parseResult.GetValue(recursiveOpt), + SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC, + Pattern = parseResult.GetRequiredValue(patternArg), + DirPath = parseResult.GetValue(dirOpt)?.FullName, + }; + return Execute(options, cancellationToken); + }); return command; } - public static int Execute(RpfOptions options, string pattern, string? dirPath = null, CancellationToken cancellationToken = default) + public static int Execute(SearchOptions options, CancellationToken cancellationToken = default) { - if (dirPath != null) - return ExecuteDirectory(options, pattern, dirPath, cancellationToken); + if (options.DirPath != null) + return ExecuteDirectory(options, cancellationToken); - string? initError = RpfService.ValidateAndLoadKeys( + string? initError = RpfHelper.ValidateAndLoadKeys( options.RpfPath, options.ExePath, options.Gen9, @@ -69,17 +105,17 @@ public static int Execute(RpfOptions options, string pattern, string? dirPath = ); if (initError != null) { - return RpfService.ReportError( + return Output.ReportError( initError, options.Json, - ErrorResult([], options, pattern) + ErrorResult([], options) ); } List scanErrors = []; try { - RpfFile rpf = RpfService.OpenRpf( + RpfFile rpf = RpfHelper.OpenRpf( options.RpfPath, options.Verbose, options.Json, @@ -89,7 +125,7 @@ public static int Execute(RpfOptions options, string pattern, string? dirPath = if (!options.Json) Console.Error.WriteLine(); - Json.SearchResult result = CollectSearch(rpf, scanErrors, options, pattern, cancellationToken: cancellationToken); + Json.SearchResult result = CollectSearch(rpf, scanErrors, options, cancellationToken: cancellationToken); if (options.Json) PrintJsonSearch(result); @@ -105,49 +141,49 @@ public static int Execute(RpfOptions options, string pattern, string? dirPath = } catch (Exception ex) { - return RpfService.ReportError( + return Output.ReportError( ex.Message, options.Json, - ErrorResult([.. scanErrors], options, pattern), + ErrorResult([.. scanErrors], options), options.Verbose ? ex.StackTrace : null ); } } - internal static int ExecuteDirectory(RpfOptions options, string pattern, string dirPath, CancellationToken cancellationToken) + internal static int ExecuteDirectory(SearchOptions options, CancellationToken cancellationToken) { - if (!Directory.Exists(dirPath)) + if (!Directory.Exists(options.DirPath)) { - return RpfService.ReportError( - $"Directory not found: {dirPath}", + return Output.ReportError( + $"Directory not found: {options.DirPath}", options.Json, - ErrorResult([], options, pattern) + ErrorResult([], options) ); } - string[] rpfPaths = Directory.GetFiles(dirPath, "*.rpf", SearchOption.AllDirectories); + string[] rpfPaths = Directory.GetFiles(options.DirPath!, "*.rpf", SearchOption.AllDirectories); Array.Sort(rpfPaths, StringComparer.OrdinalIgnoreCase); if (rpfPaths.Length == 0) { - return RpfService.ReportError( - $"No .rpf files found in: {dirPath}", + return Output.ReportError( + $"No .rpf files found in: {options.DirPath}", options.Json, - ErrorResult([], options, pattern) + ErrorResult([], options) ); } - string? initError = RpfService.ValidateExeAndLoadKeys( + string? initError = RpfHelper.ValidateExeAndLoadKeys( options.ExePath, options.Gen9, options.Json ); if (initError != null) { - return RpfService.ReportError( + return Output.ReportError( initError, options.Json, - ErrorResult([], options, pattern) + ErrorResult([], options) ); } @@ -162,14 +198,14 @@ internal static int ExecuteDirectory(RpfOptions options, string pattern, string List scanErrors = []; try { - RpfFile rpf = RpfService.OpenRpf( + RpfFile rpf = RpfHelper.OpenRpf( rpfPath, options.Verbose, options.Json, scanErrors ); - Json.SearchResult partialResult = CollectSearch(rpf, scanErrors, options, pattern, archive: rpfPath, cancellationToken: cancellationToken); + Json.SearchResult partialResult = CollectSearch(rpf, scanErrors, options, archive: rpfPath, cancellationToken: cancellationToken); rpfFiles.Add(rpfPath); allMatches.AddRange(partialResult.Matches); @@ -191,9 +227,9 @@ internal static int ExecuteDirectory(RpfOptions options, string pattern, string Json.SearchResult result = new() { Success = allScanErrors.Count == 0, - RpfFile = dirPath, + RpfFile = options.DirPath!, RpfFiles = rpfFiles, - Pattern = pattern, + Pattern = options.Pattern, PatternType = "substring", MatchCount = allMatches.Count, Matches = allMatches, @@ -208,13 +244,13 @@ internal static int ExecuteDirectory(RpfOptions options, string pattern, string return allScanErrors.Count > 0 ? 1 : 0; } - internal static Json.SearchResult ErrorResult(string[] errorMessages, RpfOptions options, string pattern) => + internal static Json.SearchResult ErrorResult(string[] errorMessages, SearchOptions options) => new() { Success = false, RpfFile = options.RpfPath, RpfFiles = [], - Pattern = pattern, + Pattern = options.Pattern, PatternType = "substring", MatchCount = 0, Matches = [], @@ -224,13 +260,12 @@ internal static Json.SearchResult ErrorResult(string[] errorMessages, RpfOptions internal static Json.SearchResult CollectSearch( RpfFile rpf, List scanErrors, - RpfOptions options, - string pattern, + SearchOptions options, string? archive = null, CancellationToken cancellationToken = default) { string archivePath = archive ?? options.RpfPath; - string normalizedPattern = pattern.Replace('\\', '/'); + string normalizedPattern = options.Pattern.Replace('\\', '/'); List allEntries = []; CollectAllEntries(rpf, options.Recursive, allEntries); @@ -254,7 +289,7 @@ internal static Json.SearchResult CollectSearch( if (entry is RpfFileEntry fileEntry) { size = fileEntry.GetFileSize(); - type = RpfService.GetFileType(fileEntry); + type = RpfHelper.GetFileType(fileEntry); ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant(); } @@ -274,7 +309,7 @@ internal static Json.SearchResult CollectSearch( Success = scanErrors.Count == 0, RpfFile = archivePath, RpfFiles = [archivePath], - Pattern = pattern, + Pattern = options.Pattern, PatternType = "substring", MatchCount = matches.Count, Matches = matches, @@ -283,9 +318,9 @@ internal static Json.SearchResult CollectSearch( } internal static void PrintJsonSearch(Json.SearchResult result) => - Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); + Console.WriteLine(JsonSerializer.Serialize(result, Output.JsonSerializerOptions)); - internal static void PrintSearch(Json.SearchResult result, RpfOptions options) + internal static void PrintSearch(Json.SearchResult result, SearchOptions options) { bool multiArchive = result.RpfFiles.Count > 1; string? lastArchive = null; diff --git a/CodeWalker.Cli/Handlers/StatHandler.cs b/CodeWalker.Cli/Handlers/StatHandler.cs index 021a9af99..8190efec5 100644 --- a/CodeWalker.Cli/Handlers/StatHandler.cs +++ b/CodeWalker.Cli/Handlers/StatHandler.cs @@ -12,17 +12,59 @@ namespace CodeWalker.Cli.Handlers; +internal sealed record StatOptions +{ + public required string RpfPath { get; init; } + public required string ExePath { get; init; } + public required bool Gen9 { get; init; } + public required string[] Filters { get; init; } + public required bool Verbose { get; init; } + public required bool Json { get; init; } + public required bool Recursive { get; init; } + public required SizeFormat SizeFormat { get; init; } +} + internal static class StatHandler { public static Command CreateCommand(CancellationToken cancellationToken = default) { - RpfCommandOptions rpfOpts = new(); - - Command command = new("stat", "Show aggregate statistics for RPF archive contents"); - rpfOpts.AddTo(command, includeThreads: false); + Option rpfOpt = CliOptions.Rpf(); + Option exeOpt = CliOptions.Exe(); + Option gen9Opt = CliOptions.Gen9(); + Option filterOpt = CliOptions.Filter(); + Option recursiveOpt = CliOptions.Recursive(); + Option verboseOpt = CliOptions.Verbose(); + Option jsonOpt = CliOptions.Json(); + Option siOpt = CliOptions.Si(); + + Command command = new("stat", "Show aggregate statistics for RPF archive contents") + { + rpfOpt, + exeOpt, + gen9Opt, + filterOpt, + recursiveOpt, + verboseOpt, + jsonOpt, + siOpt, + }; command.Aliases.Add("S"); - command.SetAction(parseResult => Execute(rpfOpts.Parse(parseResult), cancellationToken)); + command.SetAction(parseResult => + { + StatOptions options = new() + { + RpfPath = parseResult.GetValue(rpfOpt)?.FullName ?? "", + ExePath = parseResult.GetRequiredValue(exeOpt).FullName, + Gen9 = parseResult.GetValue(gen9Opt), + Filters = Filter.Normalize(parseResult.GetValue(filterOpt)), + Verbose = parseResult.GetValue(verboseOpt), + Json = parseResult.GetValue(jsonOpt), + Recursive = parseResult.GetValue(recursiveOpt), + SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC, + }; + return Execute(options, cancellationToken); + }); return command; } @@ -33,9 +75,9 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul /// The options for the stat command, including the RPF file path, filters, and output format. /// A cancellation token to observe while performing the operation. /// An integer exit code indicating success (0) or failure (1). - public static int Execute(RpfOptions options, CancellationToken cancellationToken = default) + public static int Execute(StatOptions options, CancellationToken cancellationToken = default) { - string? initError = RpfService.ValidateAndLoadKeys( + string? initError = RpfHelper.ValidateAndLoadKeys( options.RpfPath, options.ExePath, options.Gen9, @@ -43,7 +85,7 @@ public static int Execute(RpfOptions options, CancellationToken cancellationToke ); if (initError != null) { - return RpfService.ReportError( + return Output.ReportError( initError, options.Json, ErrorResult([], options) @@ -53,7 +95,7 @@ public static int Execute(RpfOptions options, CancellationToken cancellationToke List scanErrors = []; try { - RpfFile rpf = RpfService.OpenRpf( + RpfFile rpf = RpfHelper.OpenRpf( options.RpfPath, options.Verbose, options.Json, @@ -63,7 +105,7 @@ public static int Execute(RpfOptions options, CancellationToken cancellationToke if (!options.Json) Console.Error.WriteLine(); - List<(RpfFile rpf, RpfFileEntry entry)> entries = RpfService.CollectFiles( + List<(RpfFile rpf, RpfFileEntry entry)> entries = RpfHelper.CollectFiles( rpf, options.Filters, options.Recursive @@ -85,7 +127,7 @@ public static int Execute(RpfOptions options, CancellationToken cancellationToke } catch (Exception ex) { - return RpfService.ReportError( + return Output.ReportError( ex.Message, options.Json, ErrorResult([.. scanErrors], options), @@ -100,7 +142,7 @@ public static int Execute(RpfOptions options, CancellationToken cancellationToke /// An array of error messages to include in the result. /// The options used to populate the RpfFile field in the result. /// A object with success set to false, the RpfFile field set from options, and all statistics fields set to default values. - internal static Json.StatResult ErrorResult(string[] errorMessages, RpfOptions options) => + internal static Json.StatResult ErrorResult(string[] errorMessages, StatOptions options) => new() { Success = false, @@ -130,7 +172,7 @@ internal static Json.StatResult ErrorResult(string[] errorMessages, RpfOptions o internal static Json.StatResult CollectStats( List<(RpfFile rpf, RpfFileEntry entry)> entries, List scanErrors, - RpfOptions options, + StatOptions options, CancellationToken cancellationToken = default) { int resourceCount = 0; @@ -236,7 +278,7 @@ .. extStats /// /// The collected statistics to serialize. internal static void PrintJsonStats(Json.StatResult result) => - Console.WriteLine(JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions)); + Console.WriteLine(JsonSerializer.Serialize(result, Output.JsonSerializerOptions)); /// /// Prints the collected statistics to the console in a human-readable format. @@ -244,7 +286,7 @@ internal static void PrintJsonStats(Json.StatResult result) => /// The collected statistics to print. /// The options used to format size values in the output. /// A cancellation token to observe while printing. - internal static void PrintStats(Json.StatResult result, RpfOptions options, CancellationToken cancellationToken = default) + internal static void PrintStats(Json.StatResult result, StatOptions options, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); // Collect all rows for dynamic column sizing diff --git a/CodeWalker.Cli/Handlers/TreeHandler.cs b/CodeWalker.Cli/Handlers/TreeHandler.cs index 6edf0db82..e58ebcc8c 100644 --- a/CodeWalker.Cli/Handlers/TreeHandler.cs +++ b/CodeWalker.Cli/Handlers/TreeHandler.cs @@ -15,7 +15,14 @@ namespace CodeWalker.Cli.Handlers; [ExcludeFromCodeCoverage] internal sealed record TreeOptions { - public required RpfOptions Rpf { get; init; } + public required string RpfPath { get; init; } + public required string ExePath { get; init; } + public required bool Gen9 { get; init; } + public required string[] Filters { get; init; } + public required bool Verbose { get; init; } + public required bool Json { get; init; } + public required bool Recursive { get; init; } + public required SizeFormat SizeFormat { get; init; } public required int Depth { get; init; } } @@ -25,7 +32,14 @@ internal static class TreeHandler { public static Command CreateCommand(CancellationToken cancellationToken = default) { - RpfCommandOptions rpfOpts = new(); + Option rpfOpt = CliOptions.Rpf(); + Option exeOpt = CliOptions.Exe(); + Option gen9Opt = CliOptions.Gen9(); + Option filterOpt = CliOptions.Filter(); + Option recursiveOpt = CliOptions.Recursive(); + Option verboseOpt = CliOptions.Verbose(); + Option jsonOpt = CliOptions.Json(); + Option siOpt = CliOptions.Si(); Option depthOption = new("--depth", "-d") { Description = "Maximum depth to display (default: unlimited)", @@ -40,17 +54,31 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul Command command = new("tree", "Display a visual tree of the RPF directory structure") { - depthOption + rpfOpt, + exeOpt, + gen9Opt, + filterOpt, + recursiveOpt, + verboseOpt, + jsonOpt, + siOpt, + depthOption, }; - rpfOpts.AddTo(command, includeThreads: false); command.Aliases.Add("t"); command.SetAction(parseResult => { TreeOptions options = new() { - Rpf = rpfOpts.Parse(parseResult), - Depth = parseResult.GetValue(depthOption) + RpfPath = parseResult.GetValue(rpfOpt)?.FullName ?? "", + ExePath = parseResult.GetRequiredValue(exeOpt).FullName, + Gen9 = parseResult.GetValue(gen9Opt), + Filters = Filter.Normalize(parseResult.GetValue(filterOpt)), + Verbose = parseResult.GetValue(verboseOpt), + Json = parseResult.GetValue(jsonOpt), + Recursive = parseResult.GetValue(recursiveOpt), + SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC, + Depth = parseResult.GetValue(depthOption), }; return Execute(options, cancellationToken); }); @@ -60,17 +88,17 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul public static int Execute(TreeOptions options, CancellationToken cancellationToken = default) { - string? initError = RpfService.ValidateAndLoadKeys( - options.Rpf.RpfPath, - options.Rpf.ExePath, - options.Rpf.Gen9, - options.Rpf.Json + string? initError = RpfHelper.ValidateAndLoadKeys( + options.RpfPath, + options.ExePath, + options.Gen9, + options.Json ); if (initError != null) { - return RpfService.ReportError( + return Output.ReportError( initError, - options.Rpf.Json, + options.Json, ErrorResult([], options) ); } @@ -78,10 +106,10 @@ public static int Execute(TreeOptions options, CancellationToken cancellationTok List scanErrors = []; try { - RpfFile rpf = RpfService.OpenRpf( - options.Rpf.RpfPath, - options.Rpf.Verbose, - options.Rpf.Json, + RpfFile rpf = RpfHelper.OpenRpf( + options.RpfPath, + options.Verbose, + options.Json, scanErrors ); @@ -97,9 +125,9 @@ public static int Execute(TreeOptions options, CancellationToken cancellationTok ref totalDirs, cancellationToken ) with - { Name = Path.GetFileName(options.Rpf.RpfPath) + "/" }; + { Name = Path.GetFileName(options.RpfPath) + "/" }; - if (options.Rpf.Json) + if (options.Json) PrintJsonTree(rootNode, totalFiles, totalDirs, scanErrors, options); else PrintTree(rootNode, totalFiles, totalDirs, options, cancellationToken); @@ -113,11 +141,11 @@ public static int Execute(TreeOptions options, CancellationToken cancellationTok } catch (Exception ex) { - return RpfService.ReportError( + return Output.ReportError( ex.Message, - options.Rpf.Json, + options.Json, ErrorResult([.. scanErrors], options), - options.Rpf.Verbose ? ex.StackTrace : null + options.Verbose ? ex.StackTrace : null ); } } @@ -126,7 +154,7 @@ internal static Json.TreeResult ErrorResult(string[] errorMessages, TreeOptions new() { Success = false, - RpfFile = options.Rpf.RpfPath, + RpfFile = options.RpfPath, TotalFiles = 0, TotalDirs = 0, Root = null, @@ -146,7 +174,7 @@ internal static List CollectChildren(RpfDirectoryEntry dir, RpfFile r } // Add nested RPFs as expandable directories if recursive - if (options.Rpf.Recursive && dir.Files != null && rpf.Children != null) + if (options.Recursive && dir.Files != null && rpf.Children != null) { foreach (RpfFileEntry fileEntry in dir.Files) { @@ -171,7 +199,7 @@ internal static List CollectChildren(RpfDirectoryEntry dir, RpfFile r items.AddRange(dir.Files .Where(fe => !expandedRpfs.Contains(fe.Name) - && Filter.Matches(fe.Path, options.Rpf.Filters) + && Filter.Matches(fe.Path, options.Filters) ) .Select(fe => new ChildItem(fe.Name, false, fe, null))); @@ -211,7 +239,7 @@ CancellationToken cancellationToken ); // Prune empty directories when filters are active - if (options.Rpf.Filters.Length > 0 + if (options.Filters.Length > 0 && (dirNode.Children == null || dirNode.Children.Count == 0)) { continue; @@ -225,8 +253,8 @@ CancellationToken cancellationToken { Name = item.Name, Size = archiveSize, - SizeFormatted = options.Rpf.SizeFormat.ToFormattedString(archiveSize), - FileType = RpfService.GetFileType(item.ArchiveEntry) + SizeFormatted = options.SizeFormat.ToFormattedString(archiveSize), + FileType = RpfHelper.GetFileType(item.ArchiveEntry) }); } else @@ -245,8 +273,8 @@ CancellationToken cancellationToken if (item.Entry is RpfFileEntry fileEntry) { size = fileEntry.GetFileSize(); - sizeFormatted = options.Rpf.SizeFormat.ToFormattedString(size.Value); - fileType = RpfService.GetFileType(fileEntry); + sizeFormatted = options.SizeFormat.ToFormattedString(size.Value); + fileType = RpfHelper.GetFileType(fileEntry); if (fileEntry is RpfResourceFileEntry rfe) version = rfe.Version; } @@ -309,13 +337,13 @@ internal static void PrintTreeChildren( if (child.Type == "dir") { - if (options.Rpf.Verbose && child.SizeFormatted != null) + if (options.Verbose && child.SizeFormatted != null) Console.WriteLine($"{prefix}{connector}{child.Name}/ <{child.SizeFormatted}, {child.FileType}>"); else Console.WriteLine($"{prefix}{connector}{child.Name}/"); PrintTreeChildren(child, childPrefix, options, cancellationToken); } - else if (options.Rpf.Verbose && child.SizeFormatted != null) + else if (options.Verbose && child.SizeFormatted != null) { string versionStr = child.Version != null ? $" v{child.Version}" : ""; Console.WriteLine( @@ -339,7 +367,7 @@ internal static void PrintJsonTree( Json.TreeResult result = new() { Success = scanErrors.Count == 0, - RpfFile = options.Rpf.RpfPath, + RpfFile = options.RpfPath, TotalFiles = totalFiles, TotalDirs = totalDirs, Root = root, @@ -347,7 +375,7 @@ internal static void PrintJsonTree( }; Console.WriteLine( - JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + JsonSerializer.Serialize(result, Output.JsonSerializerOptions) ); } } diff --git a/CodeWalker.Cli/Handlers/ValidateHandler.cs b/CodeWalker.Cli/Handlers/ValidateHandler.cs index 2fc77734b..3d078cae5 100644 --- a/CodeWalker.Cli/Handlers/ValidateHandler.cs +++ b/CodeWalker.Cli/Handlers/ValidateHandler.cs @@ -14,7 +14,15 @@ namespace CodeWalker.Cli.Handlers; internal sealed record ValidateOptions { - public required RpfOptions Rpf { get; init; } + public required string RpfPath { get; init; } + public required string ExePath { get; init; } + public required bool Gen9 { get; init; } + public required string[] Filters { get; init; } + public required bool Verbose { get; init; } + public required bool Json { get; init; } + public required bool Recursive { get; init; } + public required int Threads { get; init; } + public required SizeFormat SizeFormat { get; init; } public required bool Progress { get; init; } } @@ -22,25 +30,49 @@ internal static class ValidateHandler { public static Command CreateCommand(CancellationToken cancellationToken = default) { - RpfCommandOptions rpfOpts = new(); - Option progressOption = new("--progress", "-P") + Option rpfOpt = CliOptions.Rpf(); + Option exeOpt = CliOptions.Exe(); + Option gen9Opt = CliOptions.Gen9(); + Option filterOpt = CliOptions.Filter(); + Option recursiveOpt = CliOptions.Recursive(); + Option verboseOpt = CliOptions.Verbose(); + Option jsonOpt = CliOptions.Json(); + Option siOpt = CliOptions.Si(); + Option threadsOpt = CliOptions.Threads(); + Option progressOpt = new("--progress", "-P") { Description = "Show progress bar during validation", }; Command command = new("validate", "Validate game file integrity by parsing RPF contents") { - progressOption, + rpfOpt, + exeOpt, + gen9Opt, + filterOpt, + recursiveOpt, + verboseOpt, + jsonOpt, + siOpt, + threadsOpt, + progressOpt, }; - rpfOpts.AddTo(command); command.Aliases.Add("val"); command.SetAction(parseResult => { ValidateOptions options = new() { - Rpf = rpfOpts.Parse(parseResult), - Progress = parseResult.GetValue(progressOption), + RpfPath = parseResult.GetRequiredValue(rpfOpt).FullName, + ExePath = parseResult.GetRequiredValue(exeOpt).FullName, + Gen9 = parseResult.GetValue(gen9Opt), + Filters = Filter.Normalize(parseResult.GetValue(filterOpt)), + Verbose = parseResult.GetValue(verboseOpt), + Json = parseResult.GetValue(jsonOpt), + Recursive = parseResult.GetValue(recursiveOpt), + Threads = parseResult.GetValue(threadsOpt), + SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC, + Progress = parseResult.GetValue(progressOpt), }; return Execute(options, cancellationToken); }); @@ -54,7 +86,7 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => new() { Success = false, - RpfFile = options.Rpf.RpfPath, + RpfFile = options.RpfPath, TotalFiles = 0, Valid = 0, Warnings = 0, @@ -64,47 +96,47 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => ErrorMessages = errorMessages, }; - string? initError = RpfService.ValidateAndLoadKeys( - options.Rpf.RpfPath, - options.Rpf.ExePath, - options.Rpf.Gen9, - options.Rpf.Json + string? initError = RpfHelper.ValidateAndLoadKeys( + options.RpfPath, + options.ExePath, + options.Gen9, + options.Json ); if (initError != null) { - return RpfService.ReportError(initError, options.Rpf.Json, ErrorResult([])); + return Output.ReportError(initError, options.Json, ErrorResult([])); } List scanErrors = []; try { - RpfFile rpf = RpfService.OpenRpf( - options.Rpf.RpfPath, - options.Rpf.Verbose, - options.Rpf.Json, + RpfFile rpf = RpfHelper.OpenRpf( + options.RpfPath, + options.Verbose, + options.Json, scanErrors ); - if (!options.Rpf.Json) + if (!options.Json) { Console.Error.WriteLine(); } - List<(RpfFile rpf, RpfFileEntry entry)> entries = RpfService.CollectFiles( + List<(RpfFile rpf, RpfFileEntry entry)> entries = RpfHelper.CollectFiles( rpf, - options.Rpf.Filters, - options.Rpf.Recursive + options.Filters, + options.Recursive ); Json.ValidateFileEntry?[] results = new Json.ValidateFileEntry?[entries.Count]; object consoleLock = new(); - using (ProgressBar progress = new(entries.Count, options.Progress && !options.Rpf.Json)) + using (ProgressBar progress = new(entries.Count, options.Progress && !options.Json)) { _ = Parallel.For( 0, entries.Count, - new ParallelOptions { MaxDegreeOfParallelism = options.Rpf.Threads, CancellationToken = cancellationToken }, + new ParallelOptions { MaxDegreeOfParallelism = options.Threads, CancellationToken = cancellationToken }, i => { (_, RpfFileEntry fileEntry) = entries[i]; @@ -126,7 +158,7 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => }; if ( - !options.Rpf.Json + !options.Json && !options.Progress && (status == "warning" || status == "error") ) @@ -149,7 +181,7 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => Message = ex.Message, }; - if (!options.Rpf.Json && !options.Progress) + if (!options.Json && !options.Progress) { lock (consoleLock) { @@ -173,14 +205,14 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => int skipped = nonNull.Count(e => e.Status == "skipped"); // In verbose mode or JSON, include all; otherwise only warnings/errors - List files = (options.Rpf.Json || options.Rpf.Verbose) + List files = (options.Json || options.Verbose) ? nonNull : nonNull.Where(e => e.Status is "warning" or "error").ToList(); Json.ValidateResult result = new() { Success = errors == 0 && scanErrors.Count == 0, - RpfFile = options.Rpf.RpfPath, + RpfFile = options.RpfPath, TotalFiles = entries.Count, Valid = valid, Warnings = warnings, @@ -190,10 +222,10 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => ErrorMessages = [.. scanErrors], }; - if (options.Rpf.Json) + if (options.Json) { Console.WriteLine( - JsonSerializer.Serialize(result, RpfService.JsonSerializerOptions) + JsonSerializer.Serialize(result, Output.JsonSerializerOptions) ); } else @@ -209,11 +241,11 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => catch (OperationCanceledException) { throw; } catch (Exception ex) { - return RpfService.ReportError( + return Output.ReportError( ex.Message, - options.Rpf.Json, + options.Json, ErrorResult([.. scanErrors]), - options.Rpf.Verbose ? ex.StackTrace : null + options.Verbose ? ex.StackTrace : null ); } } diff --git a/CodeWalker.Cli/Helpers/CliOptions.cs b/CodeWalker.Cli/Helpers/CliOptions.cs new file mode 100644 index 000000000..554aa3b57 --- /dev/null +++ b/CodeWalker.Cli/Helpers/CliOptions.cs @@ -0,0 +1,90 @@ +using System; +using System.CommandLine; +using System.IO; + +namespace CodeWalker.Cli.Helpers; + +internal static class CliOptions +{ + // From RpfCommandOptions: + public static Option Rpf() => new("--rpf", "-r") + { + Description = "Path to the RPF file", + Required = true, + }; + + // From CommonCommandOptions: + public static Option Exe(bool required = true) => new("--exe", "-e") + { + Description = "Path to the GTA V installation directory (containing GTA5.exe)", + Required = required, + }; + + public static Option Gen9() => new("--gen9", "-g") + { + Description = "Use GTA V Enhanced (Gen9) mode", + }; + + public static Option Filter() => new("--filter", "-f") + { + Description = "Filter files by glob patterns (e.g. *.ydd); can be specified multiple times", + AllowMultipleArgumentsPerToken = true, + }; + + public static Option Recursive() => new("--recursive", "-R") + { + Description = "Process nested RPF archives", + }; + + public static Option Verbose() => new("--verbose", "-v") + { + Description = "Show verbose output", + }; + + public static Option Json() => new("--json") + { + Description = "Output results in JSON format for scripting", + }; + + public static Option Si() => new("--si") + { + Description = "Use SI units (1000-based: KB, MB) instead of IEC (1024-based: KiB, MiB)", + }; + + public static Option Threads() + { + Option opt = new("--threads", "-t") + { + Description = "Number of threads for parallel processing", + DefaultValueFactory = _ => Environment.ProcessorCount, + }; + opt.Validators.Add(result => + { + if (result.GetValue(opt) < 1) + result.AddError("--threads must be at least 1."); + }); + return opt; + } + + // From ExportCommandOptions: + public static Option OutputDir() => new("--output", "-o") + { + Description = "Output directory", + DefaultValueFactory = _ => new DirectoryInfo(Directory.GetCurrentDirectory()), + }; + + public static Option DryRun() => new("--dry-run", "-n") + { + Description = "Show what would be exported without writing files", + }; + + public static Option NoOverwrite() => new("--no-overwrite") + { + Description = "Skip existing output files instead of overwriting", + }; + + public static Option Progress() => new("--progress", "-P") + { + Description = "Show progress bar during export", + }; +} diff --git a/CodeWalker.Cli/ExportService.cs b/CodeWalker.Cli/Helpers/ExportPipeline.cs similarity index 83% rename from CodeWalker.Cli/ExportService.cs rename to CodeWalker.Cli/Helpers/ExportPipeline.cs index 81430043e..9b64301ce 100644 --- a/CodeWalker.Cli/ExportService.cs +++ b/CodeWalker.Cli/Helpers/ExportPipeline.cs @@ -5,11 +5,9 @@ using System.Threading; using System.Threading.Tasks; -using CodeWalker.Cli.Handlers; -using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; -namespace CodeWalker.Cli; +namespace CodeWalker.Cli.Helpers; /// /// Delegate for processing a single file entry during export. @@ -27,7 +25,24 @@ internal delegate (Json.ExportFileEntry? entry, string? error) ExportFileProcess bool noOverwrite ); -internal static class ExportService +internal sealed record ExportOptions +{ + public required string RpfPath { get; init; } + public required string ExePath { get; init; } + public required bool Gen9 { get; init; } + public required string[] Filters { get; init; } + public required bool Verbose { get; init; } + public required bool Json { get; init; } + public required bool Recursive { get; init; } + public required int Threads { get; init; } + public required SizeFormat SizeFormat { get; init; } + public required string OutputPath { get; init; } + public required bool DryRun { get; init; } + public required bool NoOverwrite { get; init; } + public required bool Progress { get; init; } +} + +internal static class ExportPipeline { internal readonly record struct ExportAggregation { @@ -146,7 +161,7 @@ Json.ExportResult ErrorResult(string[] errorMessages) => new() { Success = false, - RpfFile = options.Rpf.RpfPath, + RpfFile = options.RpfPath, OutputDir = options.OutputPath, Format = format, TotalFiles = 0, @@ -158,26 +173,26 @@ Json.ExportResult ErrorResult(string[] errorMessages) => ErrorMessages = errorMessages, }; - string? initError = RpfService.ValidateAndLoadKeys( - options.Rpf.RpfPath, - options.Rpf.ExePath, - options.Rpf.Gen9, - options.Rpf.Json + string? initError = RpfHelper.ValidateAndLoadKeys( + options.RpfPath, + options.ExePath, + options.Gen9, + options.Json ); if (initError != null) - return RpfService.ReportError(initError, options.Rpf.Json, ErrorResult([])); + return Output.ReportError(initError, options.Json, ErrorResult([])); try { List scanErrors = []; - RpfFile rpf = RpfService.OpenRpf( - options.Rpf.RpfPath, - options.Rpf.Verbose, - options.Rpf.Json, + RpfFile rpf = RpfHelper.OpenRpf( + options.RpfPath, + options.Verbose, + options.Json, scanErrors ); - if (!options.Rpf.Json && options.DryRun) + if (!options.Json && options.DryRun) Console.Error.WriteLine("Dry run mode - no files will be exported"); string outputDir = options.OutputPath; @@ -185,13 +200,13 @@ Json.ExportResult ErrorResult(string[] errorMessages) => if (!options.DryRun && !Directory.Exists(outputDir)) _ = Directory.CreateDirectory(outputDir); - List<(RpfFile rpf, RpfFileEntry entry)> filesToExport = RpfService.CollectFiles( + List<(RpfFile rpf, RpfFileEntry entry)> filesToExport = RpfHelper.CollectFiles( rpf, - options.Rpf.Filters, - options.Rpf.Recursive + options.Filters, + options.Recursive ); - int totalNonRpfFiles = RpfService.CountNonRpfFiles(rpf, options.Rpf.Recursive); + int totalNonRpfFiles = RpfHelper.CountNonRpfFiles(rpf, options.Recursive); int filterSkipped = totalNonRpfFiles - filesToExport.Count; (Json.ExportFileEntry? jsonEntry, string? errorMessage)[] results = @@ -202,14 +217,14 @@ Json.ExportResult ErrorResult(string[] errorMessages) => using ( ProgressBar progress = new( filesToExport.Count, - options is { Progress: true, Rpf.Json: false } + options is { Progress: true, Json: false } ) ) { _ = Parallel.For( 0, filesToExport.Count, - new ParallelOptions { MaxDegreeOfParallelism = options.Rpf.Threads, CancellationToken = cancellationToken }, + new ParallelOptions { MaxDegreeOfParallelism = options.Threads, CancellationToken = cancellationToken }, i => { (RpfFile sourceRpf, RpfFileEntry fileEntry) = filesToExport[i]; @@ -232,7 +247,7 @@ Json.ExportResult ErrorResult(string[] errorMessages) => if ( result.entry != null - && options is { Rpf: { Verbose: true, Json: false }, Progress: false } + && options is { Verbose: true, Json: false, Progress: false } ) { if (options.DryRun) @@ -259,7 +274,7 @@ Json.ExportResult ErrorResult(string[] errorMessages) => } catch (Exception ex) { - if (!options.Rpf.Json) + if (!options.Json) { lock (consoleLock) { @@ -283,7 +298,7 @@ Json.ExportResult ErrorResult(string[] errorMessages) => Json.ExportResult jsonResult = new() { Success = agg.ErrorMessages.Count == 0, - RpfFile = options.Rpf.RpfPath, + RpfFile = options.RpfPath, OutputDir = options.OutputPath, Format = format, TotalFiles = totalNonRpfFiles, @@ -295,10 +310,10 @@ Json.ExportResult ErrorResult(string[] errorMessages) => ErrorMessages = agg.ErrorMessages, }; - if (options.Rpf.Json) + if (options.Json) { Console.WriteLine( - JsonSerializer.Serialize(jsonResult, RpfService.JsonSerializerOptions) + JsonSerializer.Serialize(jsonResult, Output.JsonSerializerOptions) ); } else @@ -315,11 +330,11 @@ Json.ExportResult ErrorResult(string[] errorMessages) => catch (OperationCanceledException) { throw; } catch (Exception ex) { - return RpfService.ReportError( + return Output.ReportError( ex.Message, - options.Rpf.Json, + options.Json, ErrorResult([]), - options.Rpf.Verbose ? ex.StackTrace : null + options.Verbose ? ex.StackTrace : null ); } } diff --git a/CodeWalker.Cli/Helpers/Output.cs b/CodeWalker.Cli/Helpers/Output.cs new file mode 100644 index 000000000..f70f9fcc5 --- /dev/null +++ b/CodeWalker.Cli/Helpers/Output.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeWalker.Cli.Helpers; + +internal abstract record BaseResult +{ + [JsonPropertyName("success")] + [JsonPropertyOrder(-1)] + public required bool Success { get; init; } + + [JsonPropertyName("errorMessages")] + [JsonPropertyOrder(100)] + public required IReadOnlyList ErrorMessages { get; init; } +} + +internal static class Output +{ + public static readonly JsonSerializerOptions JsonSerializerOptions = new() + { + WriteIndented = true, + }; + + /// + /// Reports an error in JSON or text format and returns exit code 1. + /// The with expression preserves the runtime (derived) type, and + /// serialises using that type so all properties are included. + /// + public static int ReportError( + string message, + bool json, + BaseResult result, + string? stackTrace = null + ) + { + if (json) + { + BaseResult errorResult = result with + { + Success = false, + ErrorMessages = [.. result.ErrorMessages, message], + }; + Console.WriteLine( + JsonSerializer.Serialize(errorResult, errorResult.GetType(), JsonSerializerOptions) + ); + } + else + { + Console.Error.WriteLine($"Error: {message}"); + if (stackTrace != null) + Console.Error.WriteLine(stackTrace); + } + return 1; + } +} diff --git a/CodeWalker.Cli/RpfService.cs b/CodeWalker.Cli/Helpers/RpfHelper.cs similarity index 78% rename from CodeWalker.Cli/RpfService.cs rename to CodeWalker.Cli/Helpers/RpfHelper.cs index 7f229ca5d..cd6ffeae9 100644 --- a/CodeWalker.Cli/RpfService.cs +++ b/CodeWalker.Cli/Helpers/RpfHelper.cs @@ -2,32 +2,13 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text.Json; -using System.Text.Json.Serialization; -using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; -namespace CodeWalker.Cli; +namespace CodeWalker.Cli.Helpers; -internal abstract record BaseResult +internal static class RpfHelper { - [JsonPropertyName("success")] - [JsonPropertyOrder(-1)] - public required bool Success { get; init; } - - [JsonPropertyName("errorMessages")] - [JsonPropertyOrder(100)] - public required IReadOnlyList ErrorMessages { get; init; } -} - -internal static class RpfService -{ - public static readonly JsonSerializerOptions JsonSerializerOptions = new() - { - WriteIndented = true, - }; - /// /// Validates that the GTA V executable exists in the given directory. /// Returns null on success, or an error message on failure. @@ -77,6 +58,62 @@ internal static class RpfService public static void LoadKeys(string exePath, bool gen9) => GTA5Keys.LoadFromPath(exePath, gen9); + /// + /// Validates inputs, loads encryption keys, and prints status to stderr. + /// Returns an error message on failure, or null on success. + /// + public static string? ValidateAndLoadKeys(string rpfPath, string exePath, bool gen9, bool json) + { + string? error = ValidateInputs(rpfPath, exePath, gen9); + if (error != null) + return error; + + if (!json) + Console.Error.WriteLine("Loading encryption keys..."); + LoadKeys(exePath, gen9); + + return null; + } + + /// + /// Opens an RPF file with standard verbose/json output handling. + /// + public static RpfFile OpenRpf( + string rpfPath, + bool verbose, + bool json, + List errorMessages + ) + { + if (!json) + Console.Error.WriteLine($"Opening RPF: {rpfPath}"); + + string rpfName = Path.GetFileName(rpfPath); + RpfFile rpf = new(rpfPath, rpfName); + rpf.ScanStructure( + status => + { + if (verbose && !json) + Console.Error.WriteLine(status); + }, + error => + { + if (!json) + Console.Error.WriteLine($"Error: {error}"); + errorMessages.Add(error); + } + ); + + if (!json) + { + Console.Error.WriteLine( + $"Found {rpf.GrandTotalFileCount} files in {rpf.GrandTotalRpfCount} archive(s)" + ); + } + + return rpf; + } + /// /// Recursively collects file entries from an RPF archive, applying glob filters. /// @@ -153,92 +190,4 @@ entry is RpfFileEntry RpfBinaryFileEntry => "binary", _ => "unknown", }; - - /// - /// Validates inputs, loads encryption keys, and prints status to stderr. - /// Returns an error message on failure, or null on success. - /// - public static string? ValidateAndLoadKeys(string rpfPath, string exePath, bool gen9, bool json) - { - string? error = ValidateInputs(rpfPath, exePath, gen9); - if (error != null) - return error; - - if (!json) - Console.Error.WriteLine("Loading encryption keys..."); - LoadKeys(exePath, gen9); - - return null; - } - - /// - /// Opens an RPF file with standard verbose/json output handling. - /// - public static RpfFile OpenRpf( - string rpfPath, - bool verbose, - bool json, - List errorMessages - ) - { - if (!json) - Console.Error.WriteLine($"Opening RPF: {rpfPath}"); - - string rpfName = Path.GetFileName(rpfPath); - RpfFile rpf = new(rpfPath, rpfName); - rpf.ScanStructure( - status => - { - if (verbose && !json) - Console.Error.WriteLine(status); - }, - error => - { - if (!json) - Console.Error.WriteLine($"Error: {error}"); - errorMessages.Add(error); - } - ); - - if (!json) - { - Console.Error.WriteLine( - $"Found {rpf.GrandTotalFileCount} files in {rpf.GrandTotalRpfCount} archive(s)" - ); - } - - return rpf; - } - - /// - /// Reports an error in JSON or text format and returns exit code 1. - /// The with expression preserves the runtime (derived) type, and - /// serialises using that type so all properties are included. - /// - public static int ReportError( - string message, - bool json, - BaseResult result, - string? stackTrace = null - ) - { - if (json) - { - BaseResult errorResult = result with - { - Success = false, - ErrorMessages = [.. result.ErrorMessages, message], - }; - Console.WriteLine( - JsonSerializer.Serialize(errorResult, errorResult.GetType(), JsonSerializerOptions) - ); - } - else - { - Console.Error.WriteLine($"Error: {message}"); - if (stackTrace != null) - Console.Error.WriteLine(stackTrace); - } - return 1; - } } diff --git a/CodeWalker.Cli/Json/DiffResult.cs b/CodeWalker.Cli/Json/DiffResult.cs index 3191da937..20210b30a 100644 --- a/CodeWalker.Cli/Json/DiffResult.cs +++ b/CodeWalker.Cli/Json/DiffResult.cs @@ -2,6 +2,8 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using CodeWalker.Cli.Helpers; + namespace CodeWalker.Cli.Json; [ExcludeFromCodeCoverage] diff --git a/CodeWalker.Cli/Json/ExportResult.cs b/CodeWalker.Cli/Json/ExportResult.cs index ce113560d..57824d5de 100644 --- a/CodeWalker.Cli/Json/ExportResult.cs +++ b/CodeWalker.Cli/Json/ExportResult.cs @@ -2,6 +2,8 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using CodeWalker.Cli.Helpers; + namespace CodeWalker.Cli.Json; [ExcludeFromCodeCoverage] diff --git a/CodeWalker.Cli/Json/ExtractResult.cs b/CodeWalker.Cli/Json/ExtractResult.cs index c86186781..b6ca79b8c 100644 --- a/CodeWalker.Cli/Json/ExtractResult.cs +++ b/CodeWalker.Cli/Json/ExtractResult.cs @@ -2,6 +2,8 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using CodeWalker.Cli.Helpers; + namespace CodeWalker.Cli.Json; [ExcludeFromCodeCoverage] diff --git a/CodeWalker.Cli/Json/Gen9Result.cs b/CodeWalker.Cli/Json/Gen9Result.cs index 55e996e30..d10a57d05 100644 --- a/CodeWalker.Cli/Json/Gen9Result.cs +++ b/CodeWalker.Cli/Json/Gen9Result.cs @@ -2,6 +2,8 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using CodeWalker.Cli.Helpers; + namespace CodeWalker.Cli.Json; [ExcludeFromCodeCoverage] diff --git a/CodeWalker.Cli/Json/HashResult.cs b/CodeWalker.Cli/Json/HashResult.cs index f6aa018ee..d70b53531 100644 --- a/CodeWalker.Cli/Json/HashResult.cs +++ b/CodeWalker.Cli/Json/HashResult.cs @@ -2,6 +2,8 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using CodeWalker.Cli.Helpers; + namespace CodeWalker.Cli.Json; [ExcludeFromCodeCoverage] diff --git a/CodeWalker.Cli/Json/InspectResult.cs b/CodeWalker.Cli/Json/InspectResult.cs index ad6f9617a..2618d1597 100644 --- a/CodeWalker.Cli/Json/InspectResult.cs +++ b/CodeWalker.Cli/Json/InspectResult.cs @@ -2,6 +2,8 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using CodeWalker.Cli.Helpers; + namespace CodeWalker.Cli.Json; [ExcludeFromCodeCoverage] diff --git a/CodeWalker.Cli/Json/ListResult.cs b/CodeWalker.Cli/Json/ListResult.cs index d5d8aeece..ba98f6f3d 100644 --- a/CodeWalker.Cli/Json/ListResult.cs +++ b/CodeWalker.Cli/Json/ListResult.cs @@ -2,6 +2,8 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using CodeWalker.Cli.Helpers; + namespace CodeWalker.Cli.Json; [ExcludeFromCodeCoverage] diff --git a/CodeWalker.Cli/Json/PackResult.cs b/CodeWalker.Cli/Json/PackResult.cs index b9e49b283..22600def1 100644 --- a/CodeWalker.Cli/Json/PackResult.cs +++ b/CodeWalker.Cli/Json/PackResult.cs @@ -1,6 +1,8 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using CodeWalker.Cli.Helpers; + namespace CodeWalker.Cli.Json; [ExcludeFromCodeCoverage] diff --git a/CodeWalker.Cli/Json/SearchResult.cs b/CodeWalker.Cli/Json/SearchResult.cs index 2dfc3ef63..97479426f 100644 --- a/CodeWalker.Cli/Json/SearchResult.cs +++ b/CodeWalker.Cli/Json/SearchResult.cs @@ -2,6 +2,8 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using CodeWalker.Cli.Helpers; + namespace CodeWalker.Cli.Json; [ExcludeFromCodeCoverage] diff --git a/CodeWalker.Cli/Json/StatResult.cs b/CodeWalker.Cli/Json/StatResult.cs index 4610f939a..4779878ba 100644 --- a/CodeWalker.Cli/Json/StatResult.cs +++ b/CodeWalker.Cli/Json/StatResult.cs @@ -2,6 +2,8 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using CodeWalker.Cli.Helpers; + namespace CodeWalker.Cli.Json; [ExcludeFromCodeCoverage] diff --git a/CodeWalker.Cli/Json/TreeResult.cs b/CodeWalker.Cli/Json/TreeResult.cs index 0992f7493..fa4cca897 100644 --- a/CodeWalker.Cli/Json/TreeResult.cs +++ b/CodeWalker.Cli/Json/TreeResult.cs @@ -2,6 +2,8 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using CodeWalker.Cli.Helpers; + namespace CodeWalker.Cli.Json; [ExcludeFromCodeCoverage] diff --git a/CodeWalker.Cli/Json/ValidateResult.cs b/CodeWalker.Cli/Json/ValidateResult.cs index 3ac644bd2..2ba54bd31 100644 --- a/CodeWalker.Cli/Json/ValidateResult.cs +++ b/CodeWalker.Cli/Json/ValidateResult.cs @@ -2,6 +2,8 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using CodeWalker.Cli.Helpers; + namespace CodeWalker.Cli.Json; [ExcludeFromCodeCoverage] diff --git a/CodeWalker.Cli/RpfOptions.cs b/CodeWalker.Cli/RpfOptions.cs deleted file mode 100644 index 44a59d948..000000000 --- a/CodeWalker.Cli/RpfOptions.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.CommandLine; -using System.Diagnostics.CodeAnalysis; -using System.IO; - -using CodeWalker.Cli.Helpers; - -namespace CodeWalker.Cli; - -[ExcludeFromCodeCoverage] -internal sealed record RpfOptions -{ - public required string RpfPath { get; init; } - public required string ExePath { get; init; } - public required bool Gen9 { get; init; } - public required string[] Filters { get; init; } - public required bool Verbose { get; init; } - public required bool Json { get; init; } - public required bool Recursive { get; init; } - public required int Threads { get; init; } - public required SizeFormat SizeFormat { get; init; } -} - -/// -/// Shared System.CommandLine option definitions for RPF-based commands. -/// Create an instance, call to register options on a command, -/// then call inside the action to build an . -/// -internal sealed class RpfCommandOptions -{ - private readonly CommonCommandOptions _commonOpts = new(); - - public Option Rpf { get; } = new("--rpf", "-r") - { - Description = "Path to the RPF file", - Required = true, - }; - - public Option Gen9 { get; } = new("--gen9", "-g") - { - Description = "Use GTA V Enhanced (Gen9) mode", - }; - - public Option Filter { get; } = new("--filter", "-f") - { - Description = "Filter files by glob patterns (e.g. *.ydd); can be specified multiple times", - AllowMultipleArgumentsPerToken = true, - }; - - public Option Recursive { get; } = new("--recursive", "-R") - { - Description = "Process nested RPF archives", - }; - - public void AddTo(Command command, bool includeThreads = true) - { - command.Add(this.Rpf); - this._commonOpts.AddTo(command, includeThreads); - command.Add(this.Gen9); - command.Add(this.Filter); - command.Add(this.Recursive); - } - - public RpfOptions Parse(ParseResult parseResult) - { - CommonOptions common = this._commonOpts.Parse(parseResult); - return new RpfOptions - { - RpfPath = parseResult.GetValue(this.Rpf)?.FullName ?? "", - ExePath = common.ExePath, - Gen9 = parseResult.GetValue(this.Gen9), - Filters = Helpers.Filter.Normalize(parseResult.GetValue(this.Filter)), - Verbose = common.Verbose, - Json = common.Json, - Recursive = parseResult.GetValue(this.Recursive), - Threads = common.Threads, - SizeFormat = common.SizeFormat, - }; - } -} diff --git a/CodeWalker.Cli/Tests/CliOptionsTests.cs b/CodeWalker.Cli/Tests/CliOptionsTests.cs new file mode 100644 index 000000000..d75c74dcc --- /dev/null +++ b/CodeWalker.Cli/Tests/CliOptionsTests.cs @@ -0,0 +1,154 @@ +using System; +using System.CommandLine; +using System.IO; + +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +public sealed class CliOptionsTests +{ + [Fact] + public void Rpf_IsRequired() + { + Option opt = CliOptions.Rpf(); + Assert.True(opt.Required); + } + + [Fact] + public void Rpf_HasAlias() + { + Option opt = CliOptions.Rpf(); + Assert.Contains("-r", opt.Aliases); + } + + [Fact] + public void Exe_RequiredByDefault() + { + Option opt = CliOptions.Exe(); + Assert.True(opt.Required); + } + + [Fact] + public void Exe_OptionalWhenSpecified() + { + Option opt = CliOptions.Exe(required: false); + Assert.False(opt.Required); + } + + [Fact] + public void Exe_HasAlias() + { + Option opt = CliOptions.Exe(); + Assert.Contains("-e", opt.Aliases); + } + + [Fact] + public void Gen9_HasAlias() + { + Option opt = CliOptions.Gen9(); + Assert.Contains("-g", opt.Aliases); + } + + [Fact] + public void Filter_AllowsMultipleArguments() + { + Option opt = CliOptions.Filter(); + Assert.True(opt.AllowMultipleArgumentsPerToken); + } + + [Fact] + public void Filter_HasAlias() + { + Option opt = CliOptions.Filter(); + Assert.Contains("-f", opt.Aliases); + } + + [Fact] + public void Recursive_HasAlias() + { + Option opt = CliOptions.Recursive(); + Assert.Contains("-R", opt.Aliases); + } + + [Fact] + public void Verbose_HasAlias() + { + Option opt = CliOptions.Verbose(); + Assert.Contains("-v", opt.Aliases); + } + + [Fact] + public void Json_HasNoAlias() + { + Option opt = CliOptions.Json(); + // --json has no short alias + Assert.DoesNotContain("-j", opt.Aliases); + } + + [Fact] + public void Threads_HasAlias() + { + Option opt = CliOptions.Threads(); + Assert.Contains("-t", opt.Aliases); + } + + [Fact] + public void Threads_DefaultIsProcessorCount() + { + Option opt = CliOptions.Threads(); + RootCommand root = [opt]; + ParseResult pr = root.Parse(""); + Assert.Equal(Environment.ProcessorCount, pr.GetValue(opt)); + } + + [Fact] + public void Threads_RejectsZero() + { + Option opt = CliOptions.Threads(); + RootCommand root = [opt]; + ParseResult pr = root.Parse("--threads 0"); + Assert.NotEmpty(pr.Errors); + } + + [Fact] + public void Threads_AcceptsOne() + { + Option opt = CliOptions.Threads(); + RootCommand root = [opt]; + ParseResult pr = root.Parse("--threads 1"); + Assert.Empty(pr.Errors); + } + + [Fact] + public void OutputDir_HasAlias() + { + Option opt = CliOptions.OutputDir(); + Assert.Contains("-o", opt.Aliases); + } + + [Fact] + public void DryRun_HasAlias() + { + Option opt = CliOptions.DryRun(); + Assert.Contains("-n", opt.Aliases); + } + + [Fact] + public void Progress_HasAlias() + { + Option opt = CliOptions.Progress(); + Assert.Contains("-P", opt.Aliases); + } + + [Fact] + public void FactoryMethods_ReturnNewInstances() + { + // Each call should return a distinct instance + Assert.NotSame(CliOptions.Rpf(), CliOptions.Rpf()); + Assert.NotSame(CliOptions.Exe(), CliOptions.Exe()); + Assert.NotSame(CliOptions.Threads(), CliOptions.Threads()); + } +} diff --git a/CodeWalker.Cli/Tests/CommonOptionsTests.cs b/CodeWalker.Cli/Tests/CommonOptionsTests.cs deleted file mode 100644 index 46a51d719..000000000 --- a/CodeWalker.Cli/Tests/CommonOptionsTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.CommandLine; - -using CodeWalker.Cli.Helpers; - -using Xunit; - -namespace CodeWalker.Cli.Tests; - -public sealed class CommonOptionsTests -{ - [Fact] - public void Parse_MapsAllValues() - { - RootCommand root = []; - CommonCommandOptions opts = new(); - opts.AddTo(root); - ParseResult pr = root.Parse("--exe /tmp/testdir --verbose --json --si --threads 4"); - Assert.Empty(pr.Errors); - CommonOptions common = opts.Parse(pr); - Assert.Equal("/tmp/testdir", common.ExePath); - Assert.True(common.Verbose); - Assert.True(common.Json); - Assert.Equal(SizeFormat.SI, common.SizeFormat); - Assert.Equal(4, common.Threads); - } - - [Fact] - public void Parse_Defaults() - { - RootCommand root = []; - CommonCommandOptions opts = new(); - opts.AddTo(root); - ParseResult pr = root.Parse("--exe /tmp/testdir"); - Assert.Empty(pr.Errors); - CommonOptions common = opts.Parse(pr); - Assert.False(common.Verbose); - Assert.False(common.Json); - Assert.Equal(SizeFormat.IEC, common.SizeFormat); - Assert.True(common.Threads >= 1); // defaults to Environment.ProcessorCount - } - - [Fact] - public void Parse_SiEnabled_ReturnsSI() - { - RootCommand root = []; - CommonCommandOptions opts = new(); - opts.AddTo(root); - ParseResult pr = root.Parse("--exe /tmp/testdir --si"); - Assert.Equal(SizeFormat.SI, opts.Parse(pr).SizeFormat); - } - - [Fact] - public void ThreadValidator_RejectsZero() - { - RootCommand root = []; - CommonCommandOptions opts = new(); - opts.AddTo(root); - ParseResult pr = root.Parse("--exe /tmp/testdir --threads 0"); - Assert.NotEmpty(pr.Errors); - } - - [Fact] - public void ThreadValidator_AcceptsOne() - { - RootCommand root = []; - CommonCommandOptions opts = new(); - opts.AddTo(root); - ParseResult pr = root.Parse("--exe /tmp/testdir --threads 1"); - Assert.Empty(pr.Errors); - } - - [Fact] - public void AddTo_IncludesThreadsByDefault() - { - RootCommand root = []; - CommonCommandOptions opts = new(); - opts.AddTo(root); - ParseResult pr = root.Parse("--exe /tmp/testdir --threads 2"); - Assert.Empty(pr.Errors); - } - - [Fact] - public void AddTo_ExcludesThreads_WhenFlagIsFalse() - { - RootCommand root = []; - CommonCommandOptions opts = new(); - opts.AddTo(root, includeThreads: false); - ParseResult pr = root.Parse("--exe /tmp/testdir --threads 2"); - Assert.NotEmpty(pr.Errors); - } -} diff --git a/CodeWalker.Cli/Tests/ExportOptionsTests.cs b/CodeWalker.Cli/Tests/ExportOptionsTests.cs deleted file mode 100644 index daaba3b29..000000000 --- a/CodeWalker.Cli/Tests/ExportOptionsTests.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.CommandLine; - -using CodeWalker.Cli.Handlers; -using CodeWalker.Cli.Helpers; - -using Xunit; - -namespace CodeWalker.Cli.Tests; - -public sealed class ExportOptionsTests -{ - [Fact] - public void Parse_MapsAllValues() - { - RootCommand root = []; - ExportCommandOptions opts = new(); - opts.AddTo(root); - ParseResult pr = root.Parse( - "--rpf /tmp/test.rpf --exe /tmp/testdir --output /tmp/out --dry-run --no-overwrite --progress --gen9 --recursive --verbose --json --si --threads 2" - ); - Assert.Empty(pr.Errors); - ExportOptions exportOpts = opts.Parse(pr); - Assert.EndsWith("out", exportOpts.OutputPath); - Assert.True(exportOpts.DryRun); - Assert.True(exportOpts.NoOverwrite); - Assert.True(exportOpts.Progress); - // Verify RPF sub-options are populated - Assert.True(exportOpts.Rpf.Gen9); - Assert.True(exportOpts.Rpf.Recursive); - Assert.True(exportOpts.Rpf.Verbose); - Assert.True(exportOpts.Rpf.Json); - Assert.Equal(SizeFormat.SI, exportOpts.Rpf.SizeFormat); - Assert.Equal(2, exportOpts.Rpf.Threads); - } - - [Fact] - public void Parse_Defaults() - { - RootCommand root = []; - ExportCommandOptions opts = new(); - opts.AddTo(root); - ParseResult pr = root.Parse("--rpf /tmp/test.rpf --exe /tmp/testdir"); - Assert.Empty(pr.Errors); - ExportOptions exportOpts = opts.Parse(pr); - Assert.False(exportOpts.DryRun); - Assert.False(exportOpts.NoOverwrite); - Assert.False(exportOpts.Progress); - Assert.NotEmpty(exportOpts.OutputPath); // defaults to cwd - } - - [Fact] - public void Parse_DryRunAlias() - { - RootCommand root = []; - ExportCommandOptions opts = new(); - opts.AddTo(root); - ParseResult pr = root.Parse("--rpf /tmp/test.rpf --exe /tmp/testdir -n"); - Assert.Empty(pr.Errors); - Assert.True(opts.Parse(pr).DryRun); - } - - [Fact] - public void Parse_ProgressAlias() - { - RootCommand root = []; - ExportCommandOptions opts = new(); - opts.AddTo(root); - ParseResult pr = root.Parse("--rpf /tmp/test.rpf --exe /tmp/testdir -P"); - Assert.Empty(pr.Errors); - Assert.True(opts.Parse(pr).Progress); - } - - [Fact] - public void Parse_OutputAlias() - { - RootCommand root = []; - ExportCommandOptions opts = new(); - opts.AddTo(root); - ParseResult pr = root.Parse("--rpf /tmp/test.rpf --exe /tmp/testdir -o /tmp/mydir"); - Assert.Empty(pr.Errors); - Assert.EndsWith("mydir", opts.Parse(pr).OutputPath); - } -} diff --git a/CodeWalker.Cli/Tests/ExportServiceTests.cs b/CodeWalker.Cli/Tests/ExportPipelineTests.cs similarity index 82% rename from CodeWalker.Cli/Tests/ExportServiceTests.cs rename to CodeWalker.Cli/Tests/ExportPipelineTests.cs index ffbd7ba78..be24878ae 100644 --- a/CodeWalker.Cli/Tests/ExportServiceTests.cs +++ b/CodeWalker.Cli/Tests/ExportPipelineTests.cs @@ -2,7 +2,6 @@ using System.IO; using System.Threading; -using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; @@ -11,23 +10,20 @@ namespace CodeWalker.Cli.Tests; [Collection("ConsoleOutput")] -public sealed class ExportServiceExecuteTests +public sealed class ExportPipelineExecuteTests { private static ExportOptions MakeOptions(bool json) => new() { - Rpf = new RpfOptions - { - RpfPath = "/nonexistent/test.rpf", - ExePath = "/nonexistent", - Gen9 = false, - Filters = [], - Verbose = false, - Json = json, - Recursive = false, - Threads = 1, - SizeFormat = SizeFormat.IEC, - }, + RpfPath = "/nonexistent/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, OutputPath = "/tmp/output", DryRun = false, NoOverwrite = false, @@ -46,7 +42,7 @@ public void Execute_ReturnsOne_WhenValidationFails_TextMode() StringWriter stderr = new(); Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = ExportService.Execute(MakeOptions(json: false), "xml", "XML", NoOpProcessor, TestContext.Current.CancellationToken); + int exitCode = ExportPipeline.Execute(MakeOptions(json: false), "xml", "XML", NoOpProcessor, TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); } @@ -67,7 +63,7 @@ public void Execute_ReturnsOne_WhenValidationFails_JsonMode() StringWriter stdout = new(); Console.SetOut(stdout); Console.SetError(new StringWriter()); - int exitCode = ExportService.Execute(MakeOptions(json: true), "xml", "XML", NoOpProcessor, TestContext.Current.CancellationToken); + int exitCode = ExportPipeline.Execute(MakeOptions(json: true), "xml", "XML", NoOpProcessor, TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); string output = stdout.ToString(); Assert.Contains("\"success\": false", output); @@ -90,7 +86,7 @@ public void Execute_WithCancelledToken_StillReturnsValidationError() Console.SetOut(new StringWriter()); StringWriter stderr = new(); Console.SetError(stderr); - int exitCode = ExportService.Execute(MakeOptions(json: false), "xml", "XML", NoOpProcessor, new CancellationToken(canceled: true)); + int exitCode = ExportPipeline.Execute(MakeOptions(json: false), "xml", "XML", NoOpProcessor, new CancellationToken(canceled: true)); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); } @@ -109,7 +105,7 @@ public void Execute_JsonError_ContainsExpectedFields() { StringWriter stdout = new(); Console.SetOut(stdout); - int exitCode = ExportService.Execute(MakeOptions(json: true), "textures", "Textures", NoOpProcessor, TestContext.Current.CancellationToken); + int exitCode = ExportPipeline.Execute(MakeOptions(json: true), "textures", "Textures", NoOpProcessor, TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); string output = stdout.ToString(); Assert.Contains("\"format\": \"textures\"", output); @@ -129,7 +125,7 @@ private static RpfBinaryFileEntry MakeEntry(string path, string name) => [Fact] public void DryRun_ReturnsEntryWithNoError() { - (Json.ExportFileEntry? entry, string? error) = ExportService.ProcessSingleFile( + (Json.ExportFileEntry? entry, string? error) = ExportPipeline.ProcessSingleFile( MakeEntry("folder/test.ydr", "test.ydr"), data: null, outputDir: "/out", @@ -146,7 +142,7 @@ public void DryRun_ReturnsEntryWithNoError() [Fact] public void DryRun_EntryHasCorrectFields() { - (Json.ExportFileEntry? entry, string? _) = ExportService.ProcessSingleFile( + (Json.ExportFileEntry? entry, string? _) = ExportPipeline.ProcessSingleFile( MakeEntry("vehicles/adder.ydr", "adder.ydr"), data: [1, 2, 3], outputDir: "/out", @@ -165,7 +161,7 @@ public void DryRun_EntryHasCorrectFields() [Fact] public void NullData_ReturnsExtractionFailure() { - (Json.ExportFileEntry? entry, string? error) = ExportService.ProcessSingleFile( + (Json.ExportFileEntry? entry, string? error) = ExportPipeline.ProcessSingleFile( MakeEntry("test.ydr", "test.ydr"), data: null, outputDir: "/out", @@ -191,7 +187,7 @@ public void ProcessorReturnsError_ReturnsFailure() Status = "error", }; - (Json.ExportFileEntry? entry, string? error) = ExportService.ProcessSingleFile( + (Json.ExportFileEntry? entry, string? error) = ExportPipeline.ProcessSingleFile( MakeEntry("test.ydr", "test.ydr"), data: [1], outputDir: "/out", @@ -215,7 +211,7 @@ public void ProcessorReturnsSuccessEntry_ReturnsSuccess() Status = "exported", }; - (Json.ExportFileEntry? entry, string? error) = ExportService.ProcessSingleFile( + (Json.ExportFileEntry? entry, string? error) = ExportPipeline.ProcessSingleFile( MakeEntry("test.ydr", "test.ydr"), data: [1], outputDir: "/out", @@ -231,7 +227,7 @@ public void ProcessorReturnsSuccessEntry_ReturnsSuccess() [Fact] public void ProcessorReturnsNullEntry_ReturnsNoResult() { - (Json.ExportFileEntry? entry, string? error) = ExportService.ProcessSingleFile( + (Json.ExportFileEntry? entry, string? error) = ExportPipeline.ProcessSingleFile( MakeEntry("test.ydr", "test.ydr"), data: [1], outputDir: "/out", @@ -256,7 +252,7 @@ public void ProcessorReturnsUnsupported_ReturnsSuccess() Status = "unsupported", }; - (Json.ExportFileEntry? entry, string? error) = ExportService.ProcessSingleFile( + (Json.ExportFileEntry? entry, string? error) = ExportPipeline.ProcessSingleFile( MakeEntry("test.ybn", "test.ybn"), data: [1], outputDir: "/out", @@ -273,7 +269,7 @@ public void ProcessorReturnsUnsupported_ReturnsSuccess() public void ProcessorThrows_ExceptionPropagates() { InvalidOperationException ex = Assert.Throws(() => - ExportService.ProcessSingleFile( + ExportPipeline.ProcessSingleFile( MakeEntry("test.ydr", "test.ydr"), data: [1], outputDir: "/out", @@ -291,7 +287,7 @@ public void OutputDirectory_ComputedFromBackslashPath() { string? capturedOutputDir = null; - _ = ExportService.ProcessSingleFile( + _ = ExportPipeline.ProcessSingleFile( MakeEntry("x64\\levels\\gta5\\vehicles.rpf\\adder.ydr", "adder.ydr"), data: [1], outputDir: "/out", @@ -337,7 +333,7 @@ private static Json.ExportFileEntry MakeFileEntry(string status) => [Fact] public void EmptyResults_AllZeros_OnlyScanErrors() { - ExportService.ExportAggregation agg = ExportService.AggregateResults( + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults( [], OneScanError, filterSkipped: 0 @@ -361,7 +357,7 @@ public void CountsExportedAndDryRun_AsExported() (MakeFileEntry("exported"), null), ]; - ExportService.ExportAggregation agg = ExportService.AggregateResults(results, [], filterSkipped: 0); + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, [], filterSkipped: 0); Assert.Equal(3, agg.Exported); } @@ -375,7 +371,7 @@ public void CountsUnsupportedAndSkipped_AsSkipped() (MakeFileEntry("skipped"), null), ]; - ExportService.ExportAggregation agg = ExportService.AggregateResults(results, [], filterSkipped: 0); + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, [], filterSkipped: 0); Assert.Equal(2, agg.Skipped); } @@ -388,7 +384,7 @@ public void AddsFilterSkipped_ToSkippedCount() (MakeFileEntry("skipped"), null), ]; - ExportService.ExportAggregation agg = ExportService.AggregateResults(results, [], filterSkipped: 5); + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, [], filterSkipped: 5); Assert.Equal(6, agg.Skipped); } @@ -403,7 +399,7 @@ public void CountsErrors_FromFailedResults() (MakeFileEntry("exported"), null), ]; - ExportService.ExportAggregation agg = ExportService.AggregateResults(results, [], filterSkipped: 0); + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, [], filterSkipped: 0); Assert.Equal(2, agg.Errors); } @@ -421,7 +417,7 @@ public void CollectsAllNonNullFileEntries() (skipped, null), ]; - ExportService.ExportAggregation agg = ExportService.AggregateResults(results, [], filterSkipped: 0); + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, [], filterSkipped: 0); Assert.Equal(2, agg.Files.Count); Assert.Same(exported, agg.Files[0]); @@ -438,7 +434,7 @@ public void ErrorEntryWithMessage_CountedAsError_AndInFiles() (errorEntry, "conversion failed"), ]; - ExportService.ExportAggregation agg = ExportService.AggregateResults(results, [], filterSkipped: 0); + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, [], filterSkipped: 0); Assert.Equal(0, agg.Exported); Assert.Equal(0, agg.Skipped); @@ -459,7 +455,7 @@ public void ExportedEntryWithError_NotCountedAsExported() (entry, "partial failure"), ]; - ExportService.ExportAggregation agg = ExportService.AggregateResults(results, [], filterSkipped: 0); + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, [], filterSkipped: 0); Assert.Equal(0, agg.Exported); Assert.Equal(1, agg.Errors); @@ -477,7 +473,7 @@ public void SkippedEntryWithError_NotCountedAsSkipped() (entry, "unexpected failure"), ]; - ExportService.ExportAggregation agg = ExportService.AggregateResults(results, [], filterSkipped: 0); + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, [], filterSkipped: 0); Assert.Equal(0, agg.Skipped); Assert.Equal(1, agg.Errors); @@ -495,7 +491,7 @@ public void ErrorStatusWithNullError_CountedAsError() (errorEntry, null), ]; - ExportService.ExportAggregation agg = ExportService.AggregateResults(results, [], filterSkipped: 0); + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, [], filterSkipped: 0); Assert.Equal(0, agg.Exported); Assert.Equal(0, agg.Skipped); @@ -515,7 +511,7 @@ public void IncludesScanErrorsAndNewErrors_InErrorMessages() (MakeFileEntry("exported"), null), ]; - ExportService.ExportAggregation agg = ExportService.AggregateResults( + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults( results, OneScanWarning, filterSkipped: 0 diff --git a/CodeWalker.Cli/Tests/Handlers/ExtractHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/ExtractHandlerTests.cs index e3254f472..0069a864d 100644 --- a/CodeWalker.Cli/Tests/Handlers/ExtractHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/ExtractHandlerTests.cs @@ -14,18 +14,15 @@ public sealed class ExtractHandlerTests private static ExtractOptions MakeOptions(string rpfPath, bool json, bool dryRun = false) => new() { - Rpf = new RpfOptions - { - RpfPath = rpfPath, - ExePath = "/nonexistent", - Gen9 = false, - Filters = [], - Verbose = false, - Json = json, - Recursive = false, - Threads = 1, - SizeFormat = SizeFormat.IEC, - }, + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, OutputPath = "/tmp/cw_extract_out", DryRun = dryRun, NoOverwrite = false, diff --git a/CodeWalker.Cli/Tests/Handlers/Gen9HandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/Gen9HandlerTests.cs index 7028c81cc..f518ee41d 100644 --- a/CodeWalker.Cli/Tests/Handlers/Gen9HandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/Gen9HandlerTests.cs @@ -21,14 +21,11 @@ private static Gen9Options MakeOptions( { InputPath = inputPath, OutputPath = outputPath, - Common = new CommonOptions - { - ExePath = exePath, - Verbose = false, - Json = json, - SizeFormat = SizeFormat.IEC, - Threads = 1, - }, + ExePath = exePath, + Verbose = false, + Json = json, + SizeFormat = SizeFormat.IEC, + Threads = 1, NoRecurse = false, NoOverwrite = false, SkipUnconverted = false, diff --git a/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs index 39805d244..6966f33ee 100644 --- a/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs @@ -4,6 +4,7 @@ using System.Threading; using CodeWalker.Cli.Handlers; +using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; using Xunit; @@ -170,7 +171,7 @@ public void SingleInput_WritesValidJson() { Json.HashResult? result = JsonSerializer.Deserialize( Capture(["test"], JenkHashInputEncoding.UTF8).Trim(), - RpfService.JsonSerializerOptions + Output.JsonSerializerOptions ); Assert.NotNull(result); Assert.True(result.Success); @@ -184,7 +185,7 @@ public void SingleInput_MatchesJenkHash() JenkHash expected = new("test", JenkHashInputEncoding.UTF8); Json.HashResult? result = JsonSerializer.Deserialize( Capture(["test"], JenkHashInputEncoding.UTF8).Trim(), - RpfService.JsonSerializerOptions + Output.JsonSerializerOptions ); Assert.NotNull(result); @@ -201,7 +202,7 @@ public void AsciiEncoding_SetsEncodingField() { Json.HashResult? result = JsonSerializer.Deserialize( Capture(["hello"], JenkHashInputEncoding.ASCII).Trim(), - RpfService.JsonSerializerOptions + Output.JsonSerializerOptions ); Assert.NotNull(result); Assert.Equal("ASCII", result.Hashes[0].Encoding); @@ -212,7 +213,7 @@ public void MultipleInputs_ReturnsAll() { Json.HashResult? result = JsonSerializer.Deserialize( Capture(["alpha", "bravo"], JenkHashInputEncoding.UTF8).Trim(), - RpfService.JsonSerializerOptions + Output.JsonSerializerOptions ); Assert.NotNull(result); Assert.Equal(2, result.Hashes.Count); @@ -360,7 +361,7 @@ public void Json_WritesValidJson() )); Json.HashResult? result = JsonSerializer.Deserialize( - sw.ToString().Trim(), RpfService.JsonSerializerOptions + sw.ToString().Trim(), Output.JsonSerializerOptions ); Assert.NotNull(result); Assert.True(result.Success); diff --git a/CodeWalker.Cli/Tests/Handlers/InspectHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/InspectHandlerTests.cs index 8cc4748f5..475e20769 100644 --- a/CodeWalker.Cli/Tests/Handlers/InspectHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/InspectHandlerTests.cs @@ -58,7 +58,7 @@ public void FormatVector3_AllNegative() => [Collection("ConsoleOutput")] public sealed class InspectHandlerExecuteTests { - private static RpfOptions MakeOptions(string rpfPath, bool json) => + private static InspectOptions MakeOptions(string rpfPath, bool json, string filePath = "some/file.ydr") => new() { RpfPath = rpfPath, @@ -68,8 +68,8 @@ private static RpfOptions MakeOptions(string rpfPath, bool json) => Verbose = false, Json = json, Recursive = false, - Threads = 1, SizeFormat = SizeFormat.IEC, + FilePath = filePath, }; [Fact] @@ -83,7 +83,7 @@ public void Execute_MissingRpf_ReturnsOne() Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = InspectHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), "some/file.ydr", TestContext.Current.CancellationToken); + int exitCode = InspectHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); @@ -106,7 +106,7 @@ public void Execute_MissingRpf_Json_ReturnsErrorJson() Console.SetOut(stdout); Console.SetError(new StringWriter()); - int exitCode = InspectHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "some/file.ydr", TestContext.Current.CancellationToken); + int exitCode = InspectHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); string output = stdout.ToString(); @@ -129,7 +129,7 @@ public void Execute_Json_ErrorContainsExpectedFields() StringWriter stdout = new(); Console.SetOut(stdout); - _ = InspectHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "some/file.ydr", TestContext.Current.CancellationToken); + _ = InspectHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); string output = stdout.ToString(); Assert.Contains("\"rpfFile\":", output); diff --git a/CodeWalker.Cli/Tests/Handlers/ListHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/ListHandlerTests.cs index 992f0bfff..4f4a6e620 100644 --- a/CodeWalker.Cli/Tests/Handlers/ListHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/ListHandlerTests.cs @@ -15,7 +15,7 @@ namespace CodeWalker.Cli.Tests.Handlers; [Collection("ConsoleOutput")] public sealed class ListHandlerTests { - private static RpfOptions MakeOptions( + private static ListOptions MakeOptions( string rpfPath = "/test/test.rpf", bool json = false, bool verbose = false, @@ -29,7 +29,6 @@ private static RpfOptions MakeOptions( Verbose = verbose, Json = json, Recursive = false, - Threads = 1, SizeFormat = sizeFormat, }; diff --git a/CodeWalker.Cli/Tests/Handlers/PackHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/PackHandlerTests.cs index dd4ee2661..5d4cb7074 100644 --- a/CodeWalker.Cli/Tests/Handlers/PackHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/PackHandlerTests.cs @@ -22,14 +22,10 @@ private static PackOptions MakeOptions( { InputPath = inputPath, OutputPath = outputPath, - Common = new CommonOptions - { - ExePath = exePath, - Verbose = false, - Json = json, - SizeFormat = SizeFormat.IEC, - Threads = 1, - }, + ExePath = exePath, + Verbose = false, + Json = json, + SizeFormat = SizeFormat.IEC, Gen9 = false, Force = force, Progress = false, diff --git a/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs index cb2004ca8..844cd0295 100644 --- a/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs @@ -15,7 +15,7 @@ namespace CodeWalker.Cli.Tests.Handlers; public sealed class SearchErrorResultTests { - private static RpfOptions MakeOptions(string rpfPath = "/test.rpf") => + private static SearchOptions MakeOptions(string rpfPath = "/test.rpf", string pattern = "*.ydr") => new() { RpfPath = rpfPath, @@ -25,49 +25,49 @@ private static RpfOptions MakeOptions(string rpfPath = "/test.rpf") => Verbose = false, Json = false, Recursive = false, - Threads = 1, SizeFormat = SizeFormat.IEC, + Pattern = pattern, }; [Fact] public void ErrorResult_SetsSuccessFalse() { - Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(), "*.ydr"); + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(pattern: "*.ydr")); Assert.False(result.Success); } [Fact] public void ErrorResult_PreservesRpfFile() { - Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions("/my/test.rpf"), "test"); + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions("/my/test.rpf", pattern: "test")); Assert.Equal("/my/test.rpf", result.RpfFile); } [Fact] public void ErrorResult_PreservesPattern() { - Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(), "adder"); + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(pattern: "adder")); Assert.Equal("adder", result.Pattern); } [Fact] public void ErrorResult_SetsPatternTypeSubstring() { - Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(), "*.ydr"); + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(pattern: "*.ydr")); Assert.Equal("substring", result.PatternType); } [Fact] public void ErrorResult_SetsMatchCountZero() { - Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(), "*.ydr"); + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(pattern: "*.ydr")); Assert.Equal(0, result.MatchCount); } [Fact] public void ErrorResult_SetsEmptyMatches() { - Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(), "*.ydr"); + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(pattern: "*.ydr")); Assert.Empty(result.Matches); } @@ -75,7 +75,7 @@ public void ErrorResult_SetsEmptyMatches() public void ErrorResult_PreservesErrorMessages() { string[] msgs = ["err1", "err2"]; - Json.SearchResult result = SearchHandler.ErrorResult(msgs, MakeOptions(), "*.ydr"); + Json.SearchResult result = SearchHandler.ErrorResult(msgs, MakeOptions(pattern: "*.ydr")); Assert.Equal(msgs, result.ErrorMessages); } } @@ -84,11 +84,12 @@ public void ErrorResult_PreservesErrorMessages() public sealed class SearchCollectSearchTests { - private static RpfOptions MakeOptions( + private static SearchOptions MakeOptions( string rpfPath = "/test.rpf", bool recursive = false, bool verbose = false, - string[]? filters = null) => + string[]? filters = null, + string pattern = "") => new() { RpfPath = rpfPath, @@ -98,8 +99,8 @@ private static RpfOptions MakeOptions( Verbose = verbose, Json = false, Recursive = recursive, - Threads = 1, SizeFormat = SizeFormat.IEC, + Pattern = pattern, }; private static RpfFile MakeRpf() => @@ -130,7 +131,7 @@ public void CollectSearch_EmptyEntries_ReturnsZeroMatches() RpfFile rpf = MakeRpf(); rpf.AllEntries = []; - Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "adder", cancellationToken: TestContext.Current.CancellationToken); + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.Equal(0, result.MatchCount); @@ -143,7 +144,7 @@ public void CollectSearch_NullEntries_ReturnsZeroMatches() RpfFile rpf = MakeRpf(); rpf.AllEntries = null; - Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "adder", cancellationToken: TestContext.Current.CancellationToken); + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.Equal(0, result.MatchCount); @@ -160,7 +161,7 @@ public void CollectSearch_SubstringMatch_FindsEntries() MakeBinary("adder.ytd", "vehicles/adder.ytd"), ]; - Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "adder", cancellationToken: TestContext.Current.CancellationToken); + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.Equal(2, result.MatchCount); @@ -178,7 +179,7 @@ public void CollectSearch_ExtensionSubstring_FindsEntries() MakeBinary("zentorno.ydr", "vehicles/zentorno.ydr"), ]; - Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), ".ydr", cancellationToken: TestContext.Current.CancellationToken); + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: ".ydr"), cancellationToken: TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.Equal(2, result.MatchCount); @@ -194,7 +195,7 @@ public void CollectSearch_MatchPopulatesAllFields() MakeBinary("adder.ydr", "vehicles/adder.ydr", fileSize: 4096), ]; - Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "adder", cancellationToken: TestContext.Current.CancellationToken); + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(1, result.MatchCount); Json.SearchMatch match = result.Matches[0]; @@ -214,7 +215,7 @@ public void CollectSearch_ResourceEntry_SetsTypeResource() MakeResource("adder.ydr", "vehicles/adder.ydr"), ]; - Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "adder", cancellationToken: TestContext.Current.CancellationToken); + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(1, result.MatchCount); Assert.Equal("resource", result.Matches[0].Type); @@ -234,7 +235,7 @@ public void CollectSearch_DirectoryEntry_SetsTypeDirectory() }, ]; - Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "vehicles", cancellationToken: TestContext.Current.CancellationToken); + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "vehicles"), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(1, result.MatchCount); Assert.Equal("directory", result.Matches[0].Type); @@ -251,7 +252,7 @@ public void CollectSearch_NoMatch_ReturnsEmpty() MakeBinary("adder.ydr", "vehicles/adder.ydr"), ]; - Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "weapons", cancellationToken: TestContext.Current.CancellationToken); + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "weapons"), cancellationToken: TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.Equal(0, result.MatchCount); @@ -268,7 +269,7 @@ public void CollectSearch_ScanErrors_SetsSuccessFalse() ]; List scanErrors = ["scan error 1"]; - Json.SearchResult result = SearchHandler.CollectSearch(rpf, scanErrors, MakeOptions(), "adder", cancellationToken: TestContext.Current.CancellationToken); + Json.SearchResult result = SearchHandler.CollectSearch(rpf, scanErrors, MakeOptions(pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); Assert.False(result.Success); Assert.Equal(1, result.MatchCount); @@ -285,7 +286,7 @@ public void CollectSearch_WithFilter_NarrowsResults() MakeBinary("adder.ytd", "vehicles/adder.ytd"), ]; - Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(filters: Filter.Normalize(["*.ydr"])), "adder", cancellationToken: TestContext.Current.CancellationToken); + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(filters: Filter.Normalize(["*.ydr"]), pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.Equal(1, result.MatchCount); @@ -302,7 +303,7 @@ public void CollectSearch_WithFilter_EmptyFilters_MatchesAll() MakeBinary("adder.ytd", "vehicles/adder.ytd"), ]; - Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "adder", cancellationToken: TestContext.Current.CancellationToken); + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); Assert.True(result.Success); Assert.Equal(2, result.MatchCount); @@ -318,7 +319,7 @@ public void CollectSearch_BackslashPattern_NormalizesAndMatches() MakeBinary("zentorno.ydr", "vehicles/zentorno.ydr"), ]; - Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "vehicles\\adder", cancellationToken: TestContext.Current.CancellationToken); + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "vehicles\\adder"), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(1, result.MatchCount); Assert.Equal("vehicles/adder.ydr", result.Matches[0].Path); @@ -334,7 +335,7 @@ public void CollectSearch_CaseInsensitive_MatchesUppercasePattern() MakeBinary("zentorno.ydr", "vehicles/zentorno.ydr"), ]; - Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "ADDER", cancellationToken: TestContext.Current.CancellationToken); + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "ADDER"), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(1, result.MatchCount); Assert.Equal("adder.ydr", result.Matches[0].Name); @@ -356,7 +357,7 @@ public void CollectSearch_NullPathEntry_IsSkipped() }, ]; - Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "adder", cancellationToken: TestContext.Current.CancellationToken); + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(0, result.MatchCount); Assert.Empty(result.Matches); @@ -368,7 +369,7 @@ public void CollectSearch_SetsRpfFileAndPattern() RpfFile rpf = MakeRpf(); rpf.AllEntries = []; - Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions("/my/archive.rpf"), "test", cancellationToken: TestContext.Current.CancellationToken); + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions("/my/archive.rpf", pattern: "test"), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("/my/archive.rpf", result.RpfFile); Assert.Equal("test", result.Pattern); @@ -508,7 +509,7 @@ private static RpfBinaryFileEntry MakeBinary(string name, string path) => FileUncompressedSize = 1024, }; - private static RpfOptions MakeOptions() => + private static SearchOptions MakeOptions(string pattern = "*") => new() { RpfPath = "/test.rpf", @@ -518,8 +519,8 @@ private static RpfOptions MakeOptions() => Verbose = false, Json = false, Recursive = false, - Threads = 1, SizeFormat = SizeFormat.IEC, + Pattern = pattern, }; [Fact] @@ -536,7 +537,7 @@ public void CollectSearch_Cancelled_ThrowsOperationCanceledException() cts.Cancel(); _ = Assert.Throws( - () => SearchHandler.CollectSearch(rpf, [], MakeOptions(), "*", cancellationToken: cts.Token) + () => SearchHandler.CollectSearch(rpf, [], MakeOptions("*"), cancellationToken: cts.Token) ); } } @@ -548,7 +549,7 @@ public sealed class SearchPrintTests { private static readonly char[] SplitChars = ['\r', '\n']; - private static RpfOptions MakeOptions(bool verbose = false, SizeFormat sizeFormat = SizeFormat.IEC) => + private static SearchOptions MakeOptions(bool verbose = false, SizeFormat sizeFormat = SizeFormat.IEC) => new() { RpfPath = "/test.rpf", @@ -558,8 +559,8 @@ private static RpfOptions MakeOptions(bool verbose = false, SizeFormat sizeForma Verbose = verbose, Json = false, Recursive = false, - Threads = 1, SizeFormat = sizeFormat, + Pattern = "", }; private static Json.SearchResult MakeResult( @@ -822,7 +823,7 @@ public void PrintJsonSearch_EmptyMatches_OutputsEmptyArray() [Collection("ConsoleOutput")] public sealed class SearchHandlerExecuteTests { - private static RpfOptions MakeOptions(string rpfPath, bool json) => + private static SearchOptions MakeOptions(string rpfPath, bool json, string pattern = "", string? dirPath = null) => new() { RpfPath = rpfPath, @@ -832,8 +833,9 @@ private static RpfOptions MakeOptions(string rpfPath, bool json) => Verbose = false, Json = json, Recursive = false, - Threads = 1, SizeFormat = SizeFormat.IEC, + Pattern = pattern, + DirPath = dirPath, }; [Fact] @@ -847,7 +849,7 @@ public void Execute_MissingRpf_ReturnsOne() Console.SetOut(new StringWriter()); Console.SetError(stderr); - int exitCode = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), "*.ydr", cancellationToken: TestContext.Current.CancellationToken); + int exitCode = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false, pattern: "*.ydr"), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); Assert.Contains("Error:", stderr.ToString()); @@ -870,7 +872,7 @@ public void Execute_MissingRpf_Json_ReturnsErrorJson() Console.SetOut(stdout); Console.SetError(new StringWriter()); - int exitCode = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "adder", cancellationToken: TestContext.Current.CancellationToken); + int exitCode = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true, pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); string output = stdout.ToString(); @@ -893,7 +895,7 @@ public void Execute_Json_ErrorContainsExpectedFields() StringWriter stdout = new(); Console.SetOut(stdout); - _ = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), "test*", cancellationToken: TestContext.Current.CancellationToken); + _ = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true, pattern: "test*"), cancellationToken: TestContext.Current.CancellationToken); string output = stdout.ToString(); Assert.Contains("\"rpfFile\":", output); @@ -915,9 +917,7 @@ public void Execute_WithDirPath_DelegatesToExecuteDirectory() Console.SetError(stderr); int exitCode = SearchHandler.Execute( - MakeOptions("/unused.rpf", json: false), - "*.ydr", - dirPath: "/nonexistent_dir_xyz_12345", + MakeOptions("/unused.rpf", json: false, pattern: "*.ydr", dirPath: "/nonexistent_dir_xyz_12345"), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); @@ -942,9 +942,7 @@ public void Execute_WithDirPath_Json_DelegatesToExecuteDirectory() Console.SetError(new StringWriter()); int exitCode = SearchHandler.Execute( - MakeOptions("/unused.rpf", json: true), - "adder", - dirPath: "/nonexistent_dir_xyz_12345", + MakeOptions("/unused.rpf", json: true, pattern: "adder", dirPath: "/nonexistent_dir_xyz_12345"), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); @@ -964,7 +962,7 @@ public void Execute_WithDirPath_Json_DelegatesToExecuteDirectory() public sealed class SearchCollectSearchArchiveTests { - private static RpfOptions MakeOptions(string rpfPath = "/test.rpf") => + private static SearchOptions MakeOptions(string rpfPath = "/test.rpf", string pattern = "") => new() { RpfPath = rpfPath, @@ -974,8 +972,8 @@ private static RpfOptions MakeOptions(string rpfPath = "/test.rpf") => Verbose = false, Json = false, Recursive = false, - Threads = 1, SizeFormat = SizeFormat.IEC, + Pattern = pattern, }; private static RpfFile MakeRpf() => @@ -997,7 +995,7 @@ public void CollectSearch_DefaultArchive_UsesRpfPath() RpfFile rpf = MakeRpf(); rpf.AllEntries = [MakeBinary("a.ydr", "a.ydr")]; - Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions("/my/archive.rpf"), "a", cancellationToken: TestContext.Current.CancellationToken); + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions("/my/archive.rpf", pattern: "a"), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("/my/archive.rpf", result.Matches[0].Archive); Assert.Equal("/my/archive.rpf", result.RpfFile); @@ -1015,7 +1013,7 @@ public void CollectSearch_ExplicitArchive_MultipleMatches_AllHaveArchiveField() MakeBinary("b.ydr", "b.ydr"), ]; - Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), ".ydr", archive: "/dir/test.rpf", cancellationToken: TestContext.Current.CancellationToken); + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: ".ydr"), archive: "/dir/test.rpf", cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(2, result.MatchCount); Assert.All(result.Matches, m => Assert.Equal("/dir/test.rpf", m.Archive)); @@ -1027,7 +1025,7 @@ public void CollectSearch_ExplicitArchive_SetsArchiveField() RpfFile rpf = MakeRpf(); rpf.AllEntries = [MakeBinary("a.ydr", "a.ydr")]; - Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(), "a", archive: "/dir/custom.rpf", cancellationToken: TestContext.Current.CancellationToken); + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "a"), archive: "/dir/custom.rpf", cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("/dir/custom.rpf", result.Matches[0].Archive); Assert.Equal("/dir/custom.rpf", result.RpfFile); @@ -1040,7 +1038,7 @@ public void CollectSearch_ExplicitArchive_SetsArchiveField() public sealed class SearchErrorResultRpfFilesTests { - private static RpfOptions MakeOptions() => + private static SearchOptions MakeOptions(string pattern = "*.ydr") => new() { RpfPath = "/test.rpf", @@ -1050,14 +1048,14 @@ private static RpfOptions MakeOptions() => Verbose = false, Json = false, Recursive = false, - Threads = 1, SizeFormat = SizeFormat.IEC, + Pattern = pattern, }; [Fact] public void ErrorResult_SetsEmptyRpfFiles() { - Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(), "*.ydr"); + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions("*.ydr")); Assert.Empty(result.RpfFiles); } } @@ -1069,7 +1067,7 @@ public sealed class SearchPrintMultiArchiveTests { private static readonly char[] SplitChars = ['\r', '\n']; - private static RpfOptions MakeOptions(bool verbose = false) => + private static SearchOptions MakeOptions(bool verbose = false) => new() { RpfPath = "/dir", @@ -1079,8 +1077,8 @@ private static RpfOptions MakeOptions(bool verbose = false) => Verbose = verbose, Json = false, Recursive = false, - Threads = 1, SizeFormat = SizeFormat.IEC, + Pattern = "", }; private static Json.SearchMatch MakeMatch(string archive, string path, string name) => @@ -1330,7 +1328,7 @@ public void RelativePath_MixedForwardAndBackslash() [Collection("ConsoleOutput")] public sealed class SearchExecuteDirectoryTests { - private static RpfOptions MakeOptions(bool json = false) => + private static SearchOptions MakeOptions(bool json = false, string pattern = "", string? dirPath = null) => new() { RpfPath = "", @@ -1340,8 +1338,9 @@ private static RpfOptions MakeOptions(bool json = false) => Verbose = false, Json = json, Recursive = false, - Threads = 1, SizeFormat = SizeFormat.IEC, + Pattern = pattern, + DirPath = dirPath, }; [Fact] @@ -1356,9 +1355,7 @@ public void ExecuteDirectory_DirNotFound_ReturnsOne() Console.SetError(stderr); int exitCode = SearchHandler.ExecuteDirectory( - MakeOptions(), - "*.ydr", - "/nonexistent_dir_xyz_12345", + MakeOptions(pattern: "*.ydr", dirPath: "/nonexistent_dir_xyz_12345"), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); @@ -1383,9 +1380,7 @@ public void ExecuteDirectory_DirNotFound_Json_ReturnsErrorJson() Console.SetError(new StringWriter()); int exitCode = SearchHandler.ExecuteDirectory( - MakeOptions(json: true), - "adder", - "/nonexistent_dir_xyz_12345", + MakeOptions(json: true, pattern: "adder", dirPath: "/nonexistent_dir_xyz_12345"), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); @@ -1414,9 +1409,7 @@ public void ExecuteDirectory_NoRpfFiles_ReturnsOne() Console.SetError(stderr); int exitCode = SearchHandler.ExecuteDirectory( - MakeOptions(), - "*.ydr", - tempDir, + MakeOptions(pattern: "*.ydr", dirPath: tempDir), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); @@ -1444,9 +1437,7 @@ public void ExecuteDirectory_NoRpfFiles_Json_ReturnsErrorJson() Console.SetError(new StringWriter()); int exitCode = SearchHandler.ExecuteDirectory( - MakeOptions(json: true), - "adder", - tempDir, + MakeOptions(json: true, pattern: "adder", dirPath: tempDir), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); @@ -1477,9 +1468,7 @@ public void ExecuteDirectory_ExeValidationFails_ReturnsOne() Console.SetError(stderr); int exitCode = SearchHandler.ExecuteDirectory( - MakeOptions(), - "*.ydr", - tempDir, + MakeOptions(pattern: "*.ydr", dirPath: tempDir), TestContext.Current.CancellationToken); Assert.Equal(1, exitCode); diff --git a/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs index 31be3154b..362f2566c 100644 --- a/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs @@ -16,7 +16,7 @@ namespace CodeWalker.Cli.Tests.Handlers; public sealed class StatErrorResultTests { - private static RpfOptions MakeOptions(string rpfPath = "/test.rpf") => + private static StatOptions MakeOptions(string rpfPath = "/test.rpf") => new() { RpfPath = rpfPath, @@ -26,7 +26,6 @@ private static RpfOptions MakeOptions(string rpfPath = "/test.rpf") => Verbose = false, Json = false, Recursive = false, - Threads = 1, SizeFormat = SizeFormat.IEC }; @@ -86,7 +85,7 @@ public void ErrorResult_ExtensionsAreEmpty() public sealed class CollectStatsTests { - private static RpfOptions MakeOptions(SizeFormat fmt = SizeFormat.IEC) => + private static StatOptions MakeOptions(SizeFormat fmt = SizeFormat.IEC) => new() { RpfPath = "/test.rpf", @@ -96,7 +95,6 @@ private static RpfOptions MakeOptions(SizeFormat fmt = SizeFormat.IEC) => Verbose = false, Json = false, Recursive = false, - Threads = 1, SizeFormat = fmt }; @@ -580,7 +578,7 @@ public void PrintJsonStats_WritesValidJson() StatHandler.PrintJsonStats(MakeResult()); Json.StatResult? parsed = JsonSerializer.Deserialize( - sw.ToString().Trim(), RpfService.JsonSerializerOptions + sw.ToString().Trim(), Output.JsonSerializerOptions ); Assert.NotNull(parsed); } @@ -667,7 +665,7 @@ public void PrintJsonStats_RoundTripsCorrectly() StatHandler.PrintJsonStats(input); Json.StatResult? parsed = JsonSerializer.Deserialize( - sw.ToString().Trim(), RpfService.JsonSerializerOptions + sw.ToString().Trim(), Output.JsonSerializerOptions ); Assert.NotNull(parsed); Assert.Equal(input.TotalFiles, parsed.TotalFiles); @@ -688,7 +686,7 @@ public void PrintJsonStats_RoundTripsCorrectly() [Collection("ConsoleOutput")] public sealed class PrintStatsTests { - private static RpfOptions MakeOptions(SizeFormat fmt = SizeFormat.IEC) => + private static StatOptions MakeOptions(SizeFormat fmt = SizeFormat.IEC) => new() { RpfPath = "/test.rpf", @@ -698,11 +696,10 @@ private static RpfOptions MakeOptions(SizeFormat fmt = SizeFormat.IEC) => Verbose = false, Json = false, Recursive = false, - Threads = 1, SizeFormat = fmt }; - private static (string stdout, string stderr) Capture(Json.StatResult result, RpfOptions? options = null) + private static (string stdout, string stderr) Capture(Json.StatResult result, StatOptions? options = null) { options ??= MakeOptions(); TextWriter origOut = Console.Out; @@ -824,7 +821,7 @@ public void PrintStats_SIFormat_UsesDecimalUnits() TotalSize = 2000, TotalSizeFormatted = SizeFormat.SI.ToFormattedString(2000) }; - RpfOptions options = MakeOptions(SizeFormat.SI); + StatOptions options = MakeOptions(SizeFormat.SI); (string stdout, string stderr) = Capture(result, options); @@ -839,7 +836,7 @@ public void PrintStats_SIFormat_UsesDecimalUnits() [Collection("ConsoleOutput")] public sealed class StatExecuteTests { - private static RpfOptions MakeOptions(string rpfPath, bool json) => + private static StatOptions MakeOptions(string rpfPath, bool json) => new() { RpfPath = rpfPath, @@ -849,7 +846,6 @@ private static RpfOptions MakeOptions(string rpfPath, bool json) => Verbose = false, Json = json, Recursive = false, - Threads = 1, SizeFormat = SizeFormat.IEC }; diff --git a/CodeWalker.Cli/Tests/Handlers/TreeHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/TreeHandlerTests.cs index 251ecd467..874df6bb4 100644 --- a/CodeWalker.Cli/Tests/Handlers/TreeHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/TreeHandlerTests.cs @@ -20,19 +20,15 @@ public sealed class TreeErrorResultTests private static TreeOptions MakeOptions(string rpfPath = "/test.rpf") => new() { - Rpf = new RpfOptions - { - RpfPath = rpfPath, - ExePath = "/nonexistent", - Gen9 = false, - Filters = [], - Verbose = false, - Json = false, - Recursive = false, - Threads = 1, - SizeFormat = SizeFormat.IEC - }, - Depth = -1 + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + SizeFormat = SizeFormat.IEC, + Depth = -1, }; [Fact] @@ -82,19 +78,15 @@ public sealed class PrintTreeTests private static TreeOptions MakeOptions(bool verbose = false) => new() { - Rpf = new RpfOptions - { - RpfPath = "/test.rpf", - ExePath = "/nonexistent", - Gen9 = false, - Filters = [], - Verbose = verbose, - Json = false, - Recursive = false, - Threads = 1, - SizeFormat = SizeFormat.IEC - }, - Depth = -1 + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = verbose, + Json = false, + Recursive = false, + SizeFormat = SizeFormat.IEC, + Depth = -1, }; private static Json.TreeNode MakeFileNode( @@ -353,19 +345,15 @@ public sealed class PrintJsonTreeTests private static TreeOptions MakeOptions(string rpfPath = "/test.rpf") => new() { - Rpf = new RpfOptions - { - RpfPath = rpfPath, - ExePath = "/nonexistent", - Gen9 = false, - Filters = [], - Verbose = false, - Json = true, - Recursive = false, - Threads = 1, - SizeFormat = SizeFormat.IEC - }, - Depth = -1 + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = true, + Recursive = false, + SizeFormat = SizeFormat.IEC, + Depth = -1, }; private static Json.TreeNode MakeFileNode( @@ -425,7 +413,7 @@ public void PrintJsonTree_WritesValidJson() string json = CaptureJson(root, 0, 0); Json.TreeResult? parsed = JsonSerializer.Deserialize( - json.Trim(), RpfService.JsonSerializerOptions + json.Trim(), Output.JsonSerializerOptions ); Assert.NotNull(parsed); } @@ -510,7 +498,7 @@ public void PrintJsonTree_RoundTripsCorrectly() string json = CaptureJson(root, 2, 1); Json.TreeResult? parsed = JsonSerializer.Deserialize( - json.Trim(), RpfService.JsonSerializerOptions + json.Trim(), Output.JsonSerializerOptions ); Assert.NotNull(parsed); Assert.True(parsed.Success); @@ -539,19 +527,15 @@ public sealed class PrintTreeChildrenCancellationTests private static TreeOptions MakeOptions() => new() { - Rpf = new RpfOptions - { - RpfPath = "/test.rpf", - ExePath = "/nonexistent", - Gen9 = false, - Filters = [], - Verbose = false, - Json = false, - Recursive = false, - Threads = 1, - SizeFormat = SizeFormat.IEC - }, - Depth = -1 + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + SizeFormat = SizeFormat.IEC, + Depth = -1, }; [Fact] @@ -625,19 +609,15 @@ public sealed class TreeExecuteTests private static TreeOptions MakeOptions(string rpfPath, bool json, int depth = -1) => new() { - Rpf = new RpfOptions - { - RpfPath = rpfPath, - ExePath = "/nonexistent", - Gen9 = false, - Filters = [], - Verbose = false, - Json = json, - Recursive = false, - Threads = 1, - SizeFormat = SizeFormat.IEC - }, - Depth = depth + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + SizeFormat = SizeFormat.IEC, + Depth = depth, }; // ── Validation failures ──────────────────────────────────────────── @@ -761,19 +741,15 @@ private static TreeOptions MakeOptions( string[]? filters = null) => new() { - Rpf = new RpfOptions - { - RpfPath = "/test.rpf", - ExePath = "/nonexistent", - Gen9 = false, - Filters = filters ?? [], - Verbose = false, - Json = false, - Recursive = recursive, - Threads = 1, - SizeFormat = SizeFormat.IEC - }, - Depth = -1 + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = filters ?? [], + Verbose = false, + Json = false, + Recursive = recursive, + SizeFormat = SizeFormat.IEC, + Depth = -1, }; private static RpfFile MakeRpf(List? children = null) @@ -994,19 +970,15 @@ private static TreeOptions MakeOptions( string[]? filters = null) => new() { - Rpf = new RpfOptions - { - RpfPath = "/test.rpf", - ExePath = "/nonexistent", - Gen9 = false, - Filters = filters ?? [], - Verbose = false, - Json = false, - Recursive = false, - Threads = 1, - SizeFormat = SizeFormat.IEC - }, - Depth = depth + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = filters ?? [], + Verbose = false, + Json = false, + Recursive = false, + SizeFormat = SizeFormat.IEC, + Depth = depth, }; private static RpfFile MakeRpf() => new("test.rpf", "/test.rpf", 0); diff --git a/CodeWalker.Cli/Tests/Handlers/ValidateHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/ValidateHandlerTests.cs index 62591ddd2..65f0d34be 100644 --- a/CodeWalker.Cli/Tests/Handlers/ValidateHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/ValidateHandlerTests.cs @@ -14,18 +14,15 @@ public sealed class ValidateHandlerTests private static ValidateOptions MakeOptions(string rpfPath, bool json) => new() { - Rpf = new RpfOptions - { - RpfPath = rpfPath, - ExePath = "/nonexistent", - Gen9 = false, - Filters = [], - Verbose = false, - Json = json, - Recursive = false, - Threads = 1, - SizeFormat = SizeFormat.IEC, - }, + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, Progress = false, }; diff --git a/CodeWalker.Cli/Tests/RpfServiceTests.cs b/CodeWalker.Cli/Tests/RpfHelperTests.cs similarity index 83% rename from CodeWalker.Cli/Tests/RpfServiceTests.cs rename to CodeWalker.Cli/Tests/RpfHelperTests.cs index 31610e671..592f060e7 100644 --- a/CodeWalker.Cli/Tests/RpfServiceTests.cs +++ b/CodeWalker.Cli/Tests/RpfHelperTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; +using CodeWalker.Cli.Helpers; using CodeWalker.GameFiles; using Xunit; @@ -9,7 +10,7 @@ namespace CodeWalker.Cli.Tests; [Collection("ConsoleOutput")] -public sealed class RpfServiceTests +public sealed class RpfHelperTests { private static string CreateTempDir() { @@ -27,7 +28,7 @@ public void ValidateExe_ReturnsNull_WhenExeExists() try { File.WriteAllBytes(Path.Combine(dir, "GTA5.exe"), []); - Assert.Null(RpfService.ValidateExe(dir, gen9: false)); + Assert.Null(RpfHelper.ValidateExe(dir, gen9: false)); } finally { Directory.Delete(dir, true); } } @@ -38,7 +39,7 @@ public void ValidateExe_ReturnsError_WhenExeMissing() string dir = CreateTempDir(); try { - string? error = RpfService.ValidateExe(dir, gen9: false); + string? error = RpfHelper.ValidateExe(dir, gen9: false); Assert.NotNull(error); Assert.Contains("GTA5.exe", error); } @@ -52,7 +53,7 @@ public void ValidateExe_Gen9_ReturnsNull_WhenEnhancedExeExists() try { File.WriteAllBytes(Path.Combine(dir, "GTA5_Enhanced.exe"), []); - Assert.Null(RpfService.ValidateExe(dir, gen9: true)); + Assert.Null(RpfHelper.ValidateExe(dir, gen9: true)); } finally { Directory.Delete(dir, true); } } @@ -63,7 +64,7 @@ public void ValidateExe_Gen9_ReturnsError_WhenEnhancedExeMissing() string dir = CreateTempDir(); try { - string? error = RpfService.ValidateExe(dir, gen9: true); + string? error = RpfHelper.ValidateExe(dir, gen9: true); Assert.NotNull(error); Assert.Contains("GTA5_Enhanced.exe", error); } @@ -81,7 +82,7 @@ public void ValidateInputs_ReturnsNull_WhenBothExist() string rpf = Path.Combine(dir, "test.rpf"); File.WriteAllBytes(rpf, []); File.WriteAllBytes(Path.Combine(dir, "GTA5.exe"), []); - Assert.Null(RpfService.ValidateInputs(rpf, dir, gen9: false)); + Assert.Null(RpfHelper.ValidateInputs(rpf, dir, gen9: false)); } finally { Directory.Delete(dir, true); } } @@ -89,7 +90,7 @@ public void ValidateInputs_ReturnsNull_WhenBothExist() [Fact] public void ValidateInputs_ReturnsError_WhenRpfMissing() { - string? error = RpfService.ValidateInputs("/nonexistent/test.rpf", "/tmp", gen9: false); + string? error = RpfHelper.ValidateInputs("/nonexistent/test.rpf", "/tmp", gen9: false); Assert.NotNull(error); Assert.Contains("RPF file not found", error); } @@ -102,7 +103,7 @@ public void ValidateInputs_ReturnsError_WhenExeMissing() { string rpf = Path.Combine(dir, "test.rpf"); File.WriteAllBytes(rpf, []); - string? error = RpfService.ValidateInputs(rpf, dir, gen9: false); + string? error = RpfHelper.ValidateInputs(rpf, dir, gen9: false); Assert.NotNull(error); Assert.Contains("GTA5.exe", error); } @@ -117,7 +118,7 @@ public void ValidateExeAndLoadKeys_ReturnsError_WhenExeMissing() string dir = CreateTempDir(); try { - string? error = RpfService.ValidateExeAndLoadKeys(dir, gen9: false, json: true); + string? error = RpfHelper.ValidateExeAndLoadKeys(dir, gen9: false, json: true); Assert.NotNull(error); Assert.Contains("GTA5.exe", error); } @@ -127,7 +128,7 @@ public void ValidateExeAndLoadKeys_ReturnsError_WhenExeMissing() [Fact] public void ValidateAndLoadKeys_ReturnsError_WhenRpfMissing() { - string? error = RpfService.ValidateAndLoadKeys( + string? error = RpfHelper.ValidateAndLoadKeys( "/nonexistent.rpf", "/tmp", gen9: false, json: true ); Assert.NotNull(error); @@ -138,11 +139,11 @@ public void ValidateAndLoadKeys_ReturnsError_WhenRpfMissing() [Fact] public void GetFileType_Resource() => - Assert.Equal("resource", RpfService.GetFileType(new RpfResourceFileEntry())); + Assert.Equal("resource", RpfHelper.GetFileType(new RpfResourceFileEntry())); [Fact] public void GetFileType_Binary() => - Assert.Equal("binary", RpfService.GetFileType(new RpfBinaryFileEntry())); + Assert.Equal("binary", RpfHelper.GetFileType(new RpfBinaryFileEntry())); private sealed class StubFileEntry : RpfFileEntry { @@ -154,7 +155,7 @@ public override void Write(DataWriter writer) { } [Fact] public void GetFileType_Unknown() => - Assert.Equal("unknown", RpfService.GetFileType(new StubFileEntry())); + Assert.Equal("unknown", RpfHelper.GetFileType(new StubFileEntry())); // --- CollectFiles --- @@ -165,7 +166,7 @@ private static RpfBinaryFileEntry MakeEntry(string name, string? path = null) => public void CollectFiles_NullEntries_ReturnsEmpty() { RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = null }; - Assert.Empty(RpfService.CollectFiles(rpf, null, recursive: false)); + Assert.Empty(RpfHelper.CollectFiles(rpf, null, recursive: false)); } [Fact] @@ -174,7 +175,7 @@ public void CollectFiles_ReturnsFileEntries() RpfBinaryFileEntry entry = MakeEntry("test.ydr"); RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = [entry] }; List<(RpfFile rpf, RpfFileEntry entry)> files = - RpfService.CollectFiles(rpf, null, recursive: false); + RpfHelper.CollectFiles(rpf, null, recursive: false); _ = Assert.Single(files); Assert.Same(entry, files[0].entry); } @@ -186,7 +187,7 @@ public void CollectFiles_SkipsRpfEntries_WhenRecursive() RpfBinaryFileEntry fileEntry = MakeEntry("test.ydr"); RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = [rpfEntry, fileEntry] }; List<(RpfFile rpf, RpfFileEntry entry)> files = - RpfService.CollectFiles(rpf, null, recursive: true); + RpfHelper.CollectFiles(rpf, null, recursive: true); _ = Assert.Single(files); Assert.Equal("test.ydr", files[0].entry.Name); } @@ -198,7 +199,7 @@ public void CollectFiles_IncludesRpfEntries_WhenNotRecursive() RpfBinaryFileEntry fileEntry = MakeEntry("test.ydr"); RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = [rpfEntry, fileEntry] }; List<(RpfFile rpf, RpfFileEntry entry)> files = - RpfService.CollectFiles(rpf, null, recursive: false); + RpfHelper.CollectFiles(rpf, null, recursive: false); Assert.Equal(2, files.Count); Assert.Contains(files, f => f.entry.Name == "nested.rpf"); Assert.Contains(files, f => f.entry.Name == "test.ydr"); @@ -214,7 +215,7 @@ public void CollectFiles_SkipsDirectoryEntries() AllEntries = [dirEntry, fileEntry], }; List<(RpfFile rpf, RpfFileEntry entry)> files = - RpfService.CollectFiles(rpf, null, recursive: false); + RpfHelper.CollectFiles(rpf, null, recursive: false); _ = Assert.Single(files); } @@ -225,7 +226,7 @@ public void CollectFiles_AppliesFilter() RpfBinaryFileEntry e2 = MakeEntry("test.ytd"); RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = [e1, e2] }; List<(RpfFile rpf, RpfFileEntry entry)> files = - RpfService.CollectFiles(rpf, ["*.ydr"], recursive: false); + RpfHelper.CollectFiles(rpf, ["*.ydr"], recursive: false); _ = Assert.Single(files); Assert.Equal("test.ydr", files[0].entry.Name); } @@ -238,7 +239,7 @@ public void CollectFiles_MultipleFilters() RpfBinaryFileEntry e3 = MakeEntry("c.yft"); RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = [e1, e2, e3] }; List<(RpfFile rpf, RpfFileEntry entry)> files = - RpfService.CollectFiles(rpf, ["*.ydr", "*.ytd"], recursive: false); + RpfHelper.CollectFiles(rpf, ["*.ydr", "*.ytd"], recursive: false); Assert.Equal(2, files.Count); } @@ -256,7 +257,7 @@ public void CollectFiles_Recursive_WithFilter() Children = [child], }; List<(RpfFile rpf, RpfFileEntry entry)> files = - RpfService.CollectFiles(parent, ["*.ydr"], recursive: true); + RpfHelper.CollectFiles(parent, ["*.ydr"], recursive: true); Assert.Equal(2, files.Count); Assert.All(files, f => Assert.EndsWith(".ydr", f.entry.Name)); } @@ -272,7 +273,7 @@ public void CollectFiles_Recursive_IncludesChildren() AllEntries = [parentEntry], Children = [child], }; - Assert.Equal(2, RpfService.CollectFiles(parent, null, recursive: true).Count); + Assert.Equal(2, RpfHelper.CollectFiles(parent, null, recursive: true).Count); } [Fact] @@ -287,7 +288,7 @@ public void CollectFiles_NonRecursive_ExcludesChildren() Children = [child], }; List<(RpfFile rpf, RpfFileEntry entry)> files = - RpfService.CollectFiles(parent, null, recursive: false); + RpfHelper.CollectFiles(parent, null, recursive: false); _ = Assert.Single(files); Assert.Equal("a.ydr", files[0].entry.Name); } @@ -303,7 +304,7 @@ public void CollectFiles_Recursive_ReturnsCorrectRpfRef() Children = [child], }; List<(RpfFile rpf, RpfFileEntry entry)> files = - RpfService.CollectFiles(parent, null, recursive: true); + RpfHelper.CollectFiles(parent, null, recursive: true); _ = Assert.Single(files); Assert.Same(child, files[0].rpf); } @@ -317,7 +318,7 @@ public void CountNonRpfFiles_CountsCorrectly() { AllEntries = [MakeEntry("a.ydr"), MakeEntry("b.ytd")], }; - Assert.Equal(2, RpfService.CountNonRpfFiles(rpf, recursive: false)); + Assert.Equal(2, RpfHelper.CountNonRpfFiles(rpf, recursive: false)); } [Fact] @@ -327,7 +328,7 @@ public void CountNonRpfFiles_SkipsRpfFiles() { AllEntries = [MakeEntry("nested.rpf"), MakeEntry("test.ydr")], }; - Assert.Equal(1, RpfService.CountNonRpfFiles(rpf, recursive: false)); + Assert.Equal(1, RpfHelper.CountNonRpfFiles(rpf, recursive: false)); } [Fact] @@ -339,7 +340,7 @@ public void CountNonRpfFiles_Recursive() AllEntries = [MakeEntry("a.ydr")], Children = [child], }; - Assert.Equal(2, RpfService.CountNonRpfFiles(parent, recursive: true)); + Assert.Equal(2, RpfHelper.CountNonRpfFiles(parent, recursive: true)); } [Fact] @@ -351,14 +352,14 @@ public void CountNonRpfFiles_NonRecursive_ExcludesChildren() AllEntries = [MakeEntry("a.ydr")], Children = [child], }; - Assert.Equal(1, RpfService.CountNonRpfFiles(parent, recursive: false)); + Assert.Equal(1, RpfHelper.CountNonRpfFiles(parent, recursive: false)); } [Fact] public void CountNonRpfFiles_NullEntries_ReturnsZero() { RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = null }; - Assert.Equal(0, RpfService.CountNonRpfFiles(rpf, recursive: false)); + Assert.Equal(0, RpfHelper.CountNonRpfFiles(rpf, recursive: false)); } [Fact] @@ -369,7 +370,7 @@ public void CountNonRpfFiles_SkipsDirectoryEntries() { AllEntries = [dirEntry, MakeEntry("a.ydr")], }; - Assert.Equal(1, RpfService.CountNonRpfFiles(rpf, recursive: false)); + Assert.Equal(1, RpfHelper.CountNonRpfFiles(rpf, recursive: false)); } // --- ReportError --- @@ -399,7 +400,7 @@ public void ReportError_ReturnsOne() { Console.SetOut(new StringWriter()); Console.SetError(new StringWriter()); - Assert.Equal(1, RpfService.ReportError("err", json: false, MakeBaseResult())); + Assert.Equal(1, Output.ReportError("err", json: false, MakeBaseResult())); } finally { @@ -418,7 +419,7 @@ public void ReportError_Json_WritesToStdout() StringWriter sw = new(); Console.SetOut(sw); Console.SetError(new StringWriter()); - _ = RpfService.ReportError("test error", json: true, MakeBaseResult()); + _ = Output.ReportError("test error", json: true, MakeBaseResult()); string output = sw.ToString(); Assert.Contains("\"success\": false", output); Assert.Contains("test error", output); @@ -440,7 +441,7 @@ public void ReportError_Json_PreservesExistingErrors() StringWriter sw = new(); Console.SetOut(sw); Console.SetError(new StringWriter()); - _ = RpfService.ReportError("new error", json: true, MakeBaseResult(["old error"])); + _ = Output.ReportError("new error", json: true, MakeBaseResult(["old error"])); string output = sw.ToString(); Assert.Contains("old error", output); Assert.Contains("new error", output); @@ -463,7 +464,7 @@ public void ReportError_Text_WritesToStderr() StringWriter stderr = new(); Console.SetOut(stdout); Console.SetError(stderr); - _ = RpfService.ReportError("test error", json: false, MakeBaseResult()); + _ = Output.ReportError("test error", json: false, MakeBaseResult()); Assert.Contains("Error: test error", stderr.ToString()); Assert.Equal("", stdout.ToString()); } @@ -482,7 +483,7 @@ public void ReportError_Text_IncludesStackTrace() { StringWriter stderr = new(); Console.SetError(stderr); - _ = RpfService.ReportError("err", json: false, MakeBaseResult(), "at Foo.Bar()"); + _ = Output.ReportError("err", json: false, MakeBaseResult(), "at Foo.Bar()"); Assert.Contains("at Foo.Bar()", stderr.ToString()); } finally { Console.SetError(origErr); } @@ -496,7 +497,7 @@ public void ReportError_Text_OmitsStackTrace_WhenNull() { StringWriter stderr = new(); Console.SetError(stderr); - _ = RpfService.ReportError("err", json: false, MakeBaseResult()); + _ = Output.ReportError("err", json: false, MakeBaseResult()); Assert.DoesNotContain("at ", stderr.ToString()); } finally { Console.SetError(origErr); } diff --git a/CodeWalker.Cli/Tests/RpfOptionsTests.cs b/CodeWalker.Cli/Tests/RpfOptionsTests.cs deleted file mode 100644 index 3a6940a26..000000000 --- a/CodeWalker.Cli/Tests/RpfOptionsTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.CommandLine; - -using CodeWalker.Cli.Helpers; - -using Xunit; - -namespace CodeWalker.Cli.Tests; - -public sealed class RpfOptionsTests -{ - [Fact] - public void Parse_MapsAllValues() - { - RootCommand root = []; - RpfCommandOptions opts = new(); - opts.AddTo(root); - ParseResult pr = root.Parse( - "--rpf /tmp/test.rpf --exe /tmp/testdir --gen9 --recursive --verbose --json --si --threads 4 --filter *.ydr" - ); - Assert.Empty(pr.Errors); - RpfOptions rpfOpts = opts.Parse(pr); - Assert.Equal("/tmp/test.rpf", rpfOpts.RpfPath); - Assert.Equal("/tmp/testdir", rpfOpts.ExePath); - Assert.True(rpfOpts.Gen9); - Assert.True(rpfOpts.Recursive); - Assert.True(rpfOpts.Verbose); - Assert.True(rpfOpts.Json); - Assert.Equal(SizeFormat.SI, rpfOpts.SizeFormat); - Assert.Equal(4, rpfOpts.Threads); - } - - [Fact] - public void Parse_Defaults() - { - RootCommand root = []; - RpfCommandOptions opts = new(); - opts.AddTo(root); - ParseResult pr = root.Parse("--rpf /tmp/test.rpf --exe /tmp/testdir"); - Assert.Empty(pr.Errors); - RpfOptions rpfOpts = opts.Parse(pr); - Assert.False(rpfOpts.Gen9); - Assert.False(rpfOpts.Recursive); - Assert.False(rpfOpts.Verbose); - Assert.False(rpfOpts.Json); - Assert.Equal(SizeFormat.IEC, rpfOpts.SizeFormat); - Assert.Empty(rpfOpts.Filters); - } - - [Fact] - public void Parse_MultipleFilters() - { - RootCommand root = []; - RpfCommandOptions opts = new(); - opts.AddTo(root); - ParseResult pr = root.Parse("--rpf /tmp/test.rpf --exe /tmp/testdir --filter *.ydr *.ytd"); - Assert.Empty(pr.Errors); - RpfOptions rpfOpts = opts.Parse(pr); - Assert.Equal(2, rpfOpts.Filters.Length); - } - - [Fact] - public void Parse_Aliases() - { - RootCommand root = []; - RpfCommandOptions opts = new(); - opts.AddTo(root); - ParseResult pr = root.Parse("-r /tmp/test.rpf -e /tmp/testdir -g -R -v -t 2"); - Assert.Empty(pr.Errors); - RpfOptions rpfOpts = opts.Parse(pr); - Assert.True(rpfOpts.Gen9); - Assert.True(rpfOpts.Recursive); - Assert.True(rpfOpts.Verbose); - Assert.Equal(2, rpfOpts.Threads); - } - - [Fact] - public void AddTo_IncludesThreadsByDefault() - { - RootCommand root = []; - RpfCommandOptions opts = new(); - opts.AddTo(root); - ParseResult pr = root.Parse("--rpf /tmp/test.rpf --exe /tmp/testdir --threads 2"); - Assert.Empty(pr.Errors); - } - - [Fact] - public void AddTo_ExcludesThreads_WhenFlagIsFalse() - { - RootCommand root = []; - RpfCommandOptions opts = new(); - opts.AddTo(root, includeThreads: false); - ParseResult pr = root.Parse("--rpf /tmp/test.rpf --exe /tmp/testdir --threads 2"); - Assert.NotEmpty(pr.Errors); - } -} From f21f467c4628a56cec3c4a9e90f6fbc5cd2b4a1d Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:10:39 +0200 Subject: [PATCH 30/45] fix(cli): set RpfManager.IsGen9 so --gen9 parses Gen9 resources --gen9 only selected the encryption keys. Resource readers consult the process-wide RpfManager.IsGen9 to pick the memory layout, and nothing set it, so every resource file parsed as legacy against an Enhanced install: export textures threw on every .ytd, validate reported nothing valid, and inspect printed garbage dimensions. Setting it in LoadKeys covers every command, since they all reach it through ValidateAndLoadKeys or ValidateExeAndLoadKeys. The pack and gen9 handlers keep their own save/restore around the flag: tests run several handlers in one process and must not leak it between them. --- CodeWalker.Cli/Helpers/RpfHelper.cs | 9 +++++++-- CodeWalker.Cli/Tests/RpfHelperTests.cs | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/CodeWalker.Cli/Helpers/RpfHelper.cs b/CodeWalker.Cli/Helpers/RpfHelper.cs index cd6ffeae9..e1871a969 100644 --- a/CodeWalker.Cli/Helpers/RpfHelper.cs +++ b/CodeWalker.Cli/Helpers/RpfHelper.cs @@ -53,10 +53,15 @@ internal static class RpfHelper } /// - /// Loads GTA V encryption keys from the installation directory. + /// Loads GTA V encryption keys and selects the resource layout for the target generation. + /// is a process-wide switch that every resource reader + /// consults, so it must be set before any resource file is parsed. /// - public static void LoadKeys(string exePath, bool gen9) => + public static void LoadKeys(string exePath, bool gen9) + { + RpfManager.IsGen9 = gen9; GTA5Keys.LoadFromPath(exePath, gen9); + } /// /// Validates inputs, loads encryption keys, and prints status to stderr. diff --git a/CodeWalker.Cli/Tests/RpfHelperTests.cs b/CodeWalker.Cli/Tests/RpfHelperTests.cs index 592f060e7..291d9ad8b 100644 --- a/CodeWalker.Cli/Tests/RpfHelperTests.cs +++ b/CodeWalker.Cli/Tests/RpfHelperTests.cs @@ -110,6 +110,30 @@ public void ValidateInputs_ReturnsError_WhenExeMissing() finally { Directory.Delete(dir, true); } } + // --- LoadKeys --- + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void LoadKeys_SelectsResourceLayout_BeforeReadingKeys(bool gen9) + { + bool previous = RpfManager.IsGen9; + string dir = CreateTempDir(); + try + { + RpfManager.IsGen9 = !gen9; + // No game executable here, so key loading throws. The layout flag is still set, + // because every resource reader consults it and it must not lag behind the keys. + _ = Assert.Throws(() => RpfHelper.LoadKeys(dir, gen9)); + Assert.Equal(gen9, RpfManager.IsGen9); + } + finally + { + RpfManager.IsGen9 = previous; + Directory.Delete(dir, true); + } + } + // --- ValidateExeAndLoadKeys / ValidateAndLoadKeys early-return --- [Fact] From 71193d285e64c8082e68c836c56fee093a329293 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:19:17 +0200 Subject: [PATCH 31/45] fix(cli): hash each diff side under its own encryption keys Keys are process-wide, so loading the left installation's keys and then the right's before opening either archive left both sides being read with the right side's keys. --left-exe and --right-exe could not both be honoured, and neither could --left-gen9 and --right-gen9. Each side is now scanned while its own keys are loaded. Metadata is collected first, which identifies the entries that share a path, size and type and therefore still need a content comparison; only those are extracted and hashed, so the amount of data read is unchanged. Entries are also keyed relative to the archive root instead of by their full path. The archive's own file name is the first path component, so comparing two archives with different file names previously reported every entry as both added and removed. --- CodeWalker.Cli/Handlers/DiffHandler.cs | 335 ++++++++++-------- .../Tests/Handlers/DiffHandlerTests.cs | 254 ++++++++++--- 2 files changed, 393 insertions(+), 196 deletions(-) diff --git a/CodeWalker.Cli/Handlers/DiffHandler.cs b/CodeWalker.Cli/Handlers/DiffHandler.cs index d2d1129fb..9d534e3ef 100644 --- a/CodeWalker.Cli/Handlers/DiffHandler.cs +++ b/CodeWalker.Cli/Handlers/DiffHandler.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.IO; using System.Linq; +using System.Security.Cryptography; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -158,27 +159,46 @@ public static int Execute(DiffOptions options, CancellationToken cancellationTok List errorMessages = []; try { + // Encryption keys are process-wide, so each archive is opened and read while its + // own installation's keys are loaded. Metadata comes first for both sides; only the + // entries that could still turn out identical are extracted and hashed. if (!options.Json) - Console.Error.WriteLine("Loading encryption keys..."); + Console.Error.WriteLine("Loading left encryption keys..."); RpfHelper.LoadKeys(options.LeftExePath, options.LeftGen9); - if (options.RightExePath != options.LeftExePath || options.RightGen9 != options.LeftGen9) - RpfHelper.LoadKeys(options.RightExePath, options.RightGen9); - RpfFile leftRpf = RpfHelper.OpenRpf( options.LeftPath, options.Verbose, options.Json, errorMessages ); + List<(RpfFile rpf, RpfFileEntry entry)> leftFiles = + RpfHelper.CollectFiles(leftRpf, null, options.Recursive); + Dictionary left = BuildMetadata(leftFiles, leftRpf.Root.Path); + if (!options.Json) + Console.Error.WriteLine("Loading right encryption keys..."); + RpfHelper.LoadKeys(options.RightExePath, options.RightGen9); RpfFile rightRpf = RpfHelper.OpenRpf( options.RightPath, options.Verbose, options.Json, errorMessages ); + List<(RpfFile rpf, RpfFileEntry entry)> rightFiles = + RpfHelper.CollectFiles(rightRpf, null, options.Recursive); + Dictionary right = BuildMetadata(rightFiles, rightRpf.Root.Path); + + HashSet candidates = FindHashCandidates(left, right); - Json.DiffResult result = CollectDiff(leftRpf, rightRpf, errorMessages, options, cancellationToken); + if (candidates.Count > 0) + { + HashEntries(rightFiles, right, candidates, rightRpf.Root.Path, "right", options, errorMessages, cancellationToken); + + RpfHelper.LoadKeys(options.LeftExePath, options.LeftGen9); + HashEntries(leftFiles, left, candidates, leftRpf.Root.Path, "left", options, errorMessages, cancellationToken); + } + + Json.DiffResult result = CompareSides(left, right, errorMessages, options); if (options.Json) PrintJsonDiff(result); @@ -219,181 +239,198 @@ internal static Json.DiffResult ErrorResult(string[] errorMessages, DiffOptions ErrorMessages = errorMessages, }; - internal static Json.DiffResult CollectDiff( - RpfFile leftRpf, - RpfFile rightRpf, - List errorMessages, - DiffOptions options, - CancellationToken cancellationToken = default) + /// + /// One archive entry as seen from a single side, with its content hash filled in + /// only for entries that need a byte-level comparison. + /// + internal sealed record SideEntry { - // Collect files from both archives - List<(RpfFile rpf, RpfFileEntry entry)> leftFiles = RpfHelper.CollectFiles( - leftRpf, - null, - options.Recursive - ); - List<(RpfFile rpf, RpfFileEntry entry)> rightFiles = RpfHelper.CollectFiles( - rightRpf, - null, - options.Recursive - ); + public required string Name { get; init; } + public required long Size { get; init; } + public required string Type { get; init; } + public string? Hash { get; init; } + } - // Build dictionaries keyed by path - Dictionary leftDict = - leftFiles.ToDictionary(f => f.entry.Path, f => f); + /// + /// Strips the containing archive's own name from an entry path, so two archives compare + /// by their contents rather than by what the files on disk happen to be called. + /// + internal static string RelativeKey(string entryPath, string rootPath) + { + if (rootPath.Length == 0 || !entryPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase)) + return entryPath; - Dictionary rightDict = - rightFiles.ToDictionary(f => f.entry.Path, f => f); + string rest = entryPath[rootPath.Length..]; + return rest.StartsWith('\\') ? rest[1..] : rest; + } - SizeFormat sizeFormat = options.SizeFormat; + internal static Dictionary BuildMetadata( + List<(RpfFile rpf, RpfFileEntry entry)> files, + string rootPath + ) => + files.ToDictionary( + f => RelativeKey(f.entry.Path, rootPath), + f => new SideEntry + { + Name = f.entry.Name, + Size = f.entry.GetFileSize(), + Type = RpfHelper.GetFileType(f.entry), + } + ); - // Find removed and modified/unchanged — entries in left that also appear in right - // need byte comparison, so parallelize this - string[] commonPaths = leftDict.Keys.Where(rightDict.ContainsKey).ToArray(); + /// + /// Paths present on both sides with matching size and type. Anything else is already + /// decided by its metadata, so its content never has to be read. + /// + internal static HashSet FindHashCandidates( + IReadOnlyDictionary left, + IReadOnlyDictionary right + ) + { + HashSet candidates = []; + foreach (KeyValuePair kvp in left) + { + if (right.TryGetValue(kvp.Key, out SideEntry? other) + && other.Size == kvp.Value.Size + && other.Type == kvp.Value.Type) + { + _ = candidates.Add(kvp.Key); + } + } + return candidates; + } + + private static void HashEntries( + List<(RpfFile rpf, RpfFileEntry entry)> files, + Dictionary side, + HashSet candidates, + string rootPath, + string label, + DiffOptions options, + List errorMessages, + CancellationToken cancellationToken + ) + { + (string key, RpfFile rpf, RpfFileEntry entry)[] targets = + [ + .. files + .Select(f => (key: RelativeKey(f.entry.Path, rootPath), f.rpf, f.entry)) + .Where(f => candidates.Contains(f.key)), + ]; - // Result per common path: false = unchanged, true = modified - bool[] isModifiedArr = new bool[commonPaths.Length]; - object errorLock = new(); + string?[] hashes = new string?[targets.Length]; + string?[] failures = new string?[targets.Length]; - using (ProgressBar progress = new(commonPaths.Length, options.Progress && !options.Json)) + using (ProgressBar progress = new(targets.Length, options.Progress && !options.Json)) { _ = Parallel.For( 0, - commonPaths.Length, + targets.Length, new ParallelOptions { MaxDegreeOfParallelism = options.Threads, CancellationToken = cancellationToken }, i => { - string path = commonPaths[i]; - (RpfFile leftRpfRef, RpfFileEntry leftEntry) = leftDict[path]; - (RpfFile rightRpfRef, RpfFileEntry rightEntry) = rightDict[path]; - - long leftSize = leftEntry.GetFileSize(); - long rightSize = rightEntry.GetFileSize(); - string leftType = RpfHelper.GetFileType(leftEntry); - string rightType = RpfHelper.GetFileType(rightEntry); - - if (leftSize != rightSize || leftType != rightType) - { - isModifiedArr[i] = true; - } + (_, RpfFile rpf, RpfFileEntry entry) = targets[i]; + byte[]? data = rpf.ExtractFile(entry); + if (data == null) + failures[i] = $"Failed to extract {label} entry: {entry.Path}"; else - { - byte[]? leftData = leftRpfRef.ExtractFile(leftEntry); - byte[]? rightData = rightRpfRef.ExtractFile(rightEntry); - - if (leftData == null || rightData == null) - { - string side = leftData == null ? "left" : "right"; - lock (errorLock) - errorMessages.Add($"Failed to extract {side} entry: {path}"); - isModifiedArr[i] = true; - } - else - { - isModifiedArr[i] = !ContentEquals(leftData, rightData); - } - } + hashes[i] = ComputeHash(data); - progress.Increment(path); + progress.Increment(entry.Path); } ); } + for (int i = 0; i < targets.Length; i++) + { + if (failures[i] != null) + { + errorMessages.Add(failures[i]!); + continue; + } + string key = targets[i].key; + side[key] = side[key] with { Hash = hashes[i] }; + } + } + + private static string ComputeHash(byte[] data) + { +#if NET5_0_OR_GREATER + return Convert.ToHexString(SHA256.HashData(data)); +#else + using SHA256 sha = SHA256.Create(); + return BitConverter.ToString(sha.ComputeHash(data)).Replace("-", string.Empty); +#endif + } + + internal static Json.DiffResult CompareSides( + IReadOnlyDictionary left, + IReadOnlyDictionary right, + List errorMessages, + DiffOptions options + ) + { + SizeFormat sizeFormat = options.SizeFormat; + + Json.DiffEntry Single(string path, SideEntry entry) => + new() + { + Path = path, + Name = entry.Name, + Type = entry.Type, + Size = entry.Size, + SizeFormatted = sizeFormat.ToFormattedString(entry.Size), + }; + List added = []; List removed = []; List modified = []; List unchanged = []; - // Aggregate common path results - for (int i = 0; i < commonPaths.Length; i++) + foreach (KeyValuePair kvp in left) { - string path = commonPaths[i]; - (_, RpfFileEntry leftEntry) = leftDict[path]; - (_, RpfFileEntry rightEntry) = rightDict[path]; + if (!right.TryGetValue(kvp.Key, out SideEntry? other)) + { + removed.Add(Single(kvp.Key, kvp.Value)); + continue; + } + + // A missing hash means the entry was never a candidate, or extraction failed. + // Either way it cannot be proven identical. + bool same = kvp.Value.Hash != null + && other.Hash != null + && string.Equals(kvp.Value.Hash, other.Hash, StringComparison.Ordinal); - if (isModifiedArr[i]) + if (same) { - long leftSize = leftEntry.GetFileSize(); - long rightSize = rightEntry.GetFileSize(); - modified.Add( - new Json.DiffEntry - { - Path = path, - Name = leftEntry.Name, - Type = RpfHelper.GetFileType(leftEntry), - LeftSize = leftSize, - LeftSizeFormatted = sizeFormat.ToFormattedString(leftSize), - RightSize = rightSize, - RightSizeFormatted = sizeFormat.ToFormattedString(rightSize), - } - ); + unchanged.Add(Single(kvp.Key, kvp.Value)); } else { - long size = leftEntry.GetFileSize(); - unchanged.Add( + modified.Add( new Json.DiffEntry { - Path = path, - Name = leftEntry.Name, - Type = RpfHelper.GetFileType(leftEntry), - Size = size, - SizeFormatted = sizeFormat.ToFormattedString(size), + Path = kvp.Key, + Name = kvp.Value.Name, + Type = kvp.Value.Type, + LeftSize = kvp.Value.Size, + LeftSizeFormatted = sizeFormat.ToFormattedString(kvp.Value.Size), + RightSize = other.Size, + RightSizeFormatted = sizeFormat.ToFormattedString(other.Size), } ); } } - // Find removed (left only) - removed.AddRange( - leftDict - .Where(kvp => !rightDict.ContainsKey(kvp.Key)) - .Select(kvp => - { - long size = kvp.Value.entry.GetFileSize(); - return new Json.DiffEntry - { - Path = kvp.Key, - Name = kvp.Value.entry.Name, - Type = RpfHelper.GetFileType(kvp.Value.entry), - Size = size, - SizeFormatted = sizeFormat.ToFormattedString(size), - }; - }) - ); - - // Find added (right only) added.AddRange( - rightDict - .Where(kvp => !leftDict.ContainsKey(kvp.Key)) - .Select(kvp => - { - long size = kvp.Value.entry.GetFileSize(); - return new Json.DiffEntry - { - Path = kvp.Key, - Name = kvp.Value.entry.Name, - Type = RpfHelper.GetFileType(kvp.Value.entry), - Size = size, - SizeFormatted = sizeFormat.ToFormattedString(size), - }; - }) + right.Where(kvp => !left.ContainsKey(kvp.Key)).Select(kvp => Single(kvp.Key, kvp.Value)) ); - // Sort alphabetically added.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path)); removed.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path)); modified.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path)); unchanged.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path)); - Json.DiffSummary summary = new() - { - AddedCount = added.Count, - RemovedCount = removed.Count, - ModifiedCount = modified.Count, - UnchangedCount = unchanged.Count, - }; - return new Json.DiffResult { Success = errorMessages.Count == 0, @@ -403,7 +440,13 @@ internal static Json.DiffResult CollectDiff( Removed = [.. removed], Modified = [.. modified], Unchanged = [.. unchanged], - Summary = summary, + Summary = new Json.DiffSummary + { + AddedCount = added.Count, + RemovedCount = removed.Count, + ModifiedCount = modified.Count, + UnchangedCount = unchanged.Count, + }, ErrorMessages = [.. errorMessages], }; } @@ -459,24 +502,4 @@ internal static void PrintDiff(Json.DiffResult result, DiffOptions options) $"Summary: {result.Summary.AddedCount} added, {result.Summary.RemovedCount} removed, {result.Summary.ModifiedCount} modified, {result.Summary.UnchangedCount} unchanged" ); } - - internal static bool ContentEquals(byte[]? a, byte[]? b) - { - if (a == null && b == null) - return true; - if (a == null || b == null) - return false; - if (a.Length != b.Length) - return false; -#if NET5_0_OR_GREATER - return a.AsSpan().SequenceEqual(b); -#else - for (int i = 0; i < a.Length; i++) - { - if (a[i] != b[i]) - return false; - } - return true; -#endif - } } diff --git a/CodeWalker.Cli/Tests/Handlers/DiffHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/DiffHandlerTests.cs index 33f0af0f9..a673c0341 100644 --- a/CodeWalker.Cli/Tests/Handlers/DiffHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/DiffHandlerTests.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using CodeWalker.Cli.Handlers; using CodeWalker.Cli.Helpers; @@ -10,73 +12,245 @@ namespace CodeWalker.Cli.Tests.Handlers; public sealed class DiffHandlerTests { - // ── ContentEquals ───────────────────────────────────────────────── + private static DiffHandler.SideEntry Entry(long size, string? hash = null, string type = "binary") => + new() + { + Name = "file.dat", + Size = size, + Type = type, + Hash = hash, + }; + + private static DiffOptions Options() => + new() + { + LeftPath = "left.rpf", + RightPath = "right.rpf", + LeftExePath = "/left", + RightExePath = "/right", + LeftGen9 = false, + RightGen9 = false, + Recursive = false, + Progress = false, + Verbose = false, + Json = false, + SizeFormat = SizeFormat.IEC, + Threads = 1, + }; + + // RelativeKey [Fact] - public void ContentEquals_BothNull_ReturnsTrue() => - Assert.True(DiffHandler.ContentEquals(null, null)); + public void RelativeKey_StripsArchiveNameAndSeparator() => + Assert.Equal( + @"data\maps\paths.ipl", + DiffHandler.RelativeKey(@"packed.rpf\data\maps\paths.ipl", "packed.rpf") + ); [Fact] - public void ContentEquals_LeftNull_ReturnsFalse() => - Assert.False(DiffHandler.ContentEquals(null, [1, 2])); + public void RelativeKey_IgnoresCaseOfArchiveName() => + Assert.Equal( + @"data\a.ipl", + DiffHandler.RelativeKey(@"Packed.RPF\data\a.ipl", "packed.rpf") + ); [Fact] - public void ContentEquals_RightNull_ReturnsFalse() => - Assert.False(DiffHandler.ContentEquals([1, 2], null)); + public void RelativeKey_LeavesPathAloneWhenPrefixDoesNotMatch() => + Assert.Equal( + @"other.rpf\data\a.ipl", + DiffHandler.RelativeKey(@"other.rpf\data\a.ipl", "packed.rpf") + ); [Fact] - public void ContentEquals_DifferentLengths_ReturnsFalse() => - Assert.False(DiffHandler.ContentEquals([1, 2], [1, 2, 3])); + public void RelativeKey_LeavesPathAloneWhenRootIsEmpty() => + Assert.Equal(@"data\a.ipl", DiffHandler.RelativeKey(@"data\a.ipl", "")); [Fact] - public void ContentEquals_SameContent_ReturnsTrue() => - Assert.True(DiffHandler.ContentEquals([1, 2, 3], [1, 2, 3])); + public void RelativeKey_DifferentlyNamedArchivesProduceEqualKeys() => + Assert.Equal( + DiffHandler.RelativeKey(@"left.rpf\data\a.ipl", "left.rpf"), + DiffHandler.RelativeKey(@"right.rpf\data\a.ipl", "right.rpf") + ); + + // FindHashCandidates [Fact] - public void ContentEquals_DifferentContent_ReturnsFalse() => - Assert.False(DiffHandler.ContentEquals([1, 2, 3], [1, 2, 4])); + public void FindHashCandidates_SameSizeAndType_IsCandidate() + { + HashSet candidates = DiffHandler.FindHashCandidates( + new Dictionary { ["a"] = Entry(10) }, + new Dictionary { ["a"] = Entry(10) } + ); + Assert.Equal(["a"], candidates); + } [Fact] - public void ContentEquals_BothEmpty_ReturnsTrue() => - Assert.True(DiffHandler.ContentEquals([], [])); + public void FindHashCandidates_DifferentSize_IsNotCandidate() + { + HashSet candidates = DiffHandler.FindHashCandidates( + new Dictionary { ["a"] = Entry(10) }, + new Dictionary { ["a"] = Entry(11) } + ); + Assert.Empty(candidates); + } [Fact] - public void ContentEquals_SingleByte_Same_ReturnsTrue() => - Assert.True(DiffHandler.ContentEquals([0xFF], [0xFF])); + public void FindHashCandidates_DifferentType_IsNotCandidate() + { + HashSet candidates = DiffHandler.FindHashCandidates( + new Dictionary { ["a"] = Entry(10, type: "binary") }, + new Dictionary { ["a"] = Entry(10, type: "resource") } + ); + Assert.Empty(candidates); + } [Fact] - public void ContentEquals_SingleByte_Different_ReturnsFalse() => - Assert.False(DiffHandler.ContentEquals([0x00], [0xFF])); + public void FindHashCandidates_PathOnOneSideOnly_IsNotCandidate() + { + HashSet candidates = DiffHandler.FindHashCandidates( + new Dictionary { ["a"] = Entry(10) }, + new Dictionary { ["b"] = Entry(10) } + ); + Assert.Empty(candidates); + } + + // CompareSides [Fact] - public void ContentEquals_DifferencesAtEnd_ReturnsFalse() => - Assert.False(DiffHandler.ContentEquals([1, 2, 3, 4, 5], [1, 2, 3, 4, 6])); + public void CompareSides_PathOnlyOnLeft_IsRemoved() + { + Json.DiffResult result = DiffHandler.CompareSides( + new Dictionary { ["a"] = Entry(10) }, + new Dictionary(), + [], + Options() + ); + Assert.Equal("a", Assert.Single(result.Removed).Path); + Assert.Empty(result.Added); + Assert.Empty(result.Modified); + Assert.Empty(result.Unchanged); + } [Fact] - public void ContentEquals_LargeIdenticalArrays_ReturnsTrue() + public void CompareSides_PathOnlyOnRight_IsAdded() { - byte[] a = new byte[10_000]; - byte[] b = new byte[10_000]; - for (int i = 0; i < a.Length; i++) - { - a[i] = (byte)(i % 256); - b[i] = (byte)(i % 256); - } - Assert.True(DiffHandler.ContentEquals(a, b)); + Json.DiffResult result = DiffHandler.CompareSides( + new Dictionary(), + new Dictionary { ["a"] = Entry(10) }, + [], + Options() + ); + Assert.Equal("a", Assert.Single(result.Added).Path); + Assert.Empty(result.Removed); } [Fact] - public void ContentEquals_LargeArrays_LastByteDiffers_ReturnsFalse() + public void CompareSides_MatchingHashes_IsUnchanged() { - byte[] a = new byte[10_000]; - byte[] b = new byte[10_000]; - for (int i = 0; i < a.Length; i++) - { - a[i] = (byte)(i % 256); - b[i] = (byte)(i % 256); - } - b[^1] = (byte)(a[^1] ^ 0xFF); - Assert.False(DiffHandler.ContentEquals(a, b)); + Json.DiffResult result = DiffHandler.CompareSides( + new Dictionary { ["a"] = Entry(10, "ABCD") }, + new Dictionary { ["a"] = Entry(10, "ABCD") }, + [], + Options() + ); + Assert.Equal("a", Assert.Single(result.Unchanged).Path); + Assert.Empty(result.Modified); + } + + [Fact] + public void CompareSides_DifferentHashes_IsModified() + { + Json.DiffResult result = DiffHandler.CompareSides( + new Dictionary { ["a"] = Entry(10, "ABCD") }, + new Dictionary { ["a"] = Entry(10, "DCBA") }, + [], + Options() + ); + Assert.Equal("a", Assert.Single(result.Modified).Path); + Assert.Empty(result.Unchanged); + } + + [Fact] + public void CompareSides_DifferentSize_IsModified_WithBothSizes() + { + Json.DiffResult result = DiffHandler.CompareSides( + new Dictionary { ["a"] = Entry(10) }, + new Dictionary { ["a"] = Entry(20) }, + [], + Options() + ); + Json.DiffEntry entry = Assert.Single(result.Modified); + Assert.Equal(10, entry.LeftSize); + Assert.Equal(20, entry.RightSize); + } + + [Fact] + public void CompareSides_UnhashedEntry_IsModified_NotUnchanged() + { + // An entry whose content could not be read is never reported as identical. + Json.DiffResult result = DiffHandler.CompareSides( + new Dictionary { ["a"] = Entry(10) }, + new Dictionary { ["a"] = Entry(10, "ABCD") }, + [], + Options() + ); + Assert.Single(result.Modified); + Assert.Empty(result.Unchanged); + } + + [Fact] + public void CompareSides_SortsEachCategoryByPath() + { + Json.DiffResult result = DiffHandler.CompareSides( + new Dictionary + { + ["c"] = Entry(1), + ["a"] = Entry(1), + ["b"] = Entry(1), + }, + new Dictionary(), + [], + Options() + ); + Assert.Equal(["a", "b", "c"], result.Removed.Select(e => e.Path)); + } + + [Fact] + public void CompareSides_SummaryMatchesCategoryCounts() + { + Json.DiffResult result = DiffHandler.CompareSides( + new Dictionary + { + ["same"] = Entry(1, "AA"), + ["changed"] = Entry(1, "AA"), + ["gone"] = Entry(1), + }, + new Dictionary + { + ["same"] = Entry(1, "AA"), + ["changed"] = Entry(1, "BB"), + ["new"] = Entry(1), + }, + [], + Options() + ); + Assert.Equal(1, result.Summary.AddedCount); + Assert.Equal(1, result.Summary.RemovedCount); + Assert.Equal(1, result.Summary.ModifiedCount); + Assert.Equal(1, result.Summary.UnchangedCount); + } + + [Fact] + public void CompareSides_ErrorMessages_MarkResultUnsuccessful() + { + Json.DiffResult result = DiffHandler.CompareSides( + new Dictionary(), + new Dictionary(), + ["Failed to extract left entry: a"], + Options() + ); + Assert.False(result.Success); + Assert.Single(result.ErrorMessages); } } From 085f1ce15f04cc1d1dd748313754ba1b867a87ba Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:21:58 +0200 Subject: [PATCH 32/45] fix(cli): report post-filter totals in extract and export Both commands reported every file in the archive as totalFiles and folded the filter's exclusions into the skipped count, so extracting a single file out of common.rpf read as '1 extracted, 664 skipped' out of 665. list and stat already counted what the filter selected. totalFiles is now the selected set and skipped counts only real skips: an existing output under --no-overwrite, or a format the exporter does not handle. RpfHelper.CountNonRpfFiles existed only to produce the old archive-wide number and is gone. --- CodeWalker.Cli/Handlers/ExtractHandler.cs | 13 +---- CodeWalker.Cli/Helpers/ExportPipeline.cs | 12 +--- CodeWalker.Cli/Helpers/RpfHelper.cs | 27 --------- CodeWalker.Cli/Tests/ExportPipelineTests.cs | 32 ++++++----- CodeWalker.Cli/Tests/RpfHelperTests.cs | 64 --------------------- 5 files changed, 23 insertions(+), 125 deletions(-) diff --git a/CodeWalker.Cli/Handlers/ExtractHandler.cs b/CodeWalker.Cli/Handlers/ExtractHandler.cs index 61e2ac286..af499604b 100644 --- a/CodeWalker.Cli/Handlers/ExtractHandler.cs +++ b/CodeWalker.Cli/Handlers/ExtractHandler.cs @@ -138,17 +138,13 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => _ = Directory.CreateDirectory(outputDir); } - // Collect files first for progress bar List<(RpfFile rpf, RpfFileEntry entry)> filesToExtract = RpfHelper.CollectFiles( rpf, options.Filters, options.Recursive ); - // Count non-RPF files that were excluded by filters - int totalNonRpfFiles = RpfHelper.CountNonRpfFiles(rpf, options.Recursive); - int skipped = totalNonRpfFiles - filesToExtract.Count; - int overwriteSkipped = 0; + int skipped = 0; // Process files in parallel, storing results by index to preserve order (Json.FileEntry? jsonEntry, string? errorMessage)[] results = @@ -205,7 +201,7 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => } else if (options.NoOverwrite && File.Exists(outputPath)) { - _ = Interlocked.Increment(ref overwriteSkipped); + _ = Interlocked.Increment(ref skipped); if (options.Verbose && !options.Json && !options.Progress) { lock (consoleLock) @@ -282,7 +278,6 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => ); } - // Aggregate results in order int extracted = 0; int errors = 0; List files = []; @@ -303,14 +298,12 @@ Json.ExtractResult ErrorResult(string[] errorMessages) => } } - skipped += overwriteSkipped; - Json.ExtractResult result = new() { Success = errors == 0 && scanErrors.Count == 0, RpfFile = options.RpfPath, OutputDir = options.OutputPath ?? Directory.GetCurrentDirectory(), - TotalFiles = totalNonRpfFiles, + TotalFiles = filesToExtract.Count, Extracted = extracted, Skipped = skipped, Errors = errors, diff --git a/CodeWalker.Cli/Helpers/ExportPipeline.cs b/CodeWalker.Cli/Helpers/ExportPipeline.cs index 9b64301ce..46cf5e026 100644 --- a/CodeWalker.Cli/Helpers/ExportPipeline.cs +++ b/CodeWalker.Cli/Helpers/ExportPipeline.cs @@ -104,8 +104,7 @@ ExportFileProcessor processor internal static ExportAggregation AggregateResults( (Json.ExportFileEntry? jsonEntry, string? errorMessage)[] results, - IReadOnlyList scanErrors, - int filterSkipped + IReadOnlyList scanErrors ) { int exported = 0; @@ -137,8 +136,6 @@ int filterSkipped } } - skipped += filterSkipped; - return new ExportAggregation { Exported = exported, @@ -206,9 +203,6 @@ Json.ExportResult ErrorResult(string[] errorMessages) => options.Recursive ); - int totalNonRpfFiles = RpfHelper.CountNonRpfFiles(rpf, options.Recursive); - int filterSkipped = totalNonRpfFiles - filesToExport.Count; - (Json.ExportFileEntry? jsonEntry, string? errorMessage)[] results = new (Json.ExportFileEntry?, string?)[filesToExport.Count]; @@ -293,7 +287,7 @@ Json.ExportResult ErrorResult(string[] errorMessages) => ); } - ExportAggregation agg = AggregateResults(results, scanErrors, filterSkipped); + ExportAggregation agg = AggregateResults(results, scanErrors); Json.ExportResult jsonResult = new() { @@ -301,7 +295,7 @@ Json.ExportResult ErrorResult(string[] errorMessages) => RpfFile = options.RpfPath, OutputDir = options.OutputPath, Format = format, - TotalFiles = totalNonRpfFiles, + TotalFiles = filesToExport.Count, Exported = agg.Exported, Skipped = agg.Skipped, Errors = agg.Errors, diff --git a/CodeWalker.Cli/Helpers/RpfHelper.cs b/CodeWalker.Cli/Helpers/RpfHelper.cs index e1871a969..e4500800b 100644 --- a/CodeWalker.Cli/Helpers/RpfHelper.cs +++ b/CodeWalker.Cli/Helpers/RpfHelper.cs @@ -159,33 +159,6 @@ private static void CollectFilesRecursive( } } - /// - /// Counts non-RPF files in the archive, optionally recursing into nested RPFs. - /// - public static int CountNonRpfFiles(RpfFile rpf, bool recursive) - { - int count = 0; - CountNonRpfFilesRecursive(rpf, recursive, ref count); - return count; - } - - private static void CountNonRpfFilesRecursive(RpfFile rpf, bool recursive, ref int count) - { - if (rpf.AllEntries != null) - { - count += rpf.AllEntries - .Count(entry => - entry is RpfFileEntry - && !entry.NameLower.EndsWith(".rpf", StringComparison.Ordinal)); - } - - if (recursive && rpf.Children != null) - { - foreach (RpfFile child in rpf.Children) - CountNonRpfFilesRecursive(child, recursive, ref count); - } - } - /// /// Returns the file type string for a given RPF file entry. /// diff --git a/CodeWalker.Cli/Tests/ExportPipelineTests.cs b/CodeWalker.Cli/Tests/ExportPipelineTests.cs index be24878ae..d5df77b4b 100644 --- a/CodeWalker.Cli/Tests/ExportPipelineTests.cs +++ b/CodeWalker.Cli/Tests/ExportPipelineTests.cs @@ -335,8 +335,7 @@ public void EmptyResults_AllZeros_OnlyScanErrors() { ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults( [], - OneScanError, - filterSkipped: 0 + OneScanError ); Assert.Equal(0, agg.Exported); @@ -357,7 +356,7 @@ public void CountsExportedAndDryRun_AsExported() (MakeFileEntry("exported"), null), ]; - ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, [], filterSkipped: 0); + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, []); Assert.Equal(3, agg.Exported); } @@ -371,22 +370,26 @@ public void CountsUnsupportedAndSkipped_AsSkipped() (MakeFileEntry("skipped"), null), ]; - ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, [], filterSkipped: 0); + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, []); Assert.Equal(2, agg.Skipped); } [Fact] - public void AddsFilterSkipped_ToSkippedCount() + public void CountsOnlyProcessedEntries_AsSkipped() { + // Files excluded by --filter are never handed to the pipeline, so they must + // not turn up in the skipped count. (Json.ExportFileEntry?, string?)[] results = [ (MakeFileEntry("skipped"), null), + (MakeFileEntry("exported"), null), ]; - ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, [], filterSkipped: 5); + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, []); - Assert.Equal(6, agg.Skipped); + Assert.Equal(1, agg.Skipped); + Assert.Equal(1, agg.Exported); } [Fact] @@ -399,7 +402,7 @@ public void CountsErrors_FromFailedResults() (MakeFileEntry("exported"), null), ]; - ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, [], filterSkipped: 0); + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, []); Assert.Equal(2, agg.Errors); } @@ -417,7 +420,7 @@ public void CollectsAllNonNullFileEntries() (skipped, null), ]; - ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, [], filterSkipped: 0); + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, []); Assert.Equal(2, agg.Files.Count); Assert.Same(exported, agg.Files[0]); @@ -434,7 +437,7 @@ public void ErrorEntryWithMessage_CountedAsError_AndInFiles() (errorEntry, "conversion failed"), ]; - ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, [], filterSkipped: 0); + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, []); Assert.Equal(0, agg.Exported); Assert.Equal(0, agg.Skipped); @@ -455,7 +458,7 @@ public void ExportedEntryWithError_NotCountedAsExported() (entry, "partial failure"), ]; - ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, [], filterSkipped: 0); + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, []); Assert.Equal(0, agg.Exported); Assert.Equal(1, agg.Errors); @@ -473,7 +476,7 @@ public void SkippedEntryWithError_NotCountedAsSkipped() (entry, "unexpected failure"), ]; - ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, [], filterSkipped: 0); + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, []); Assert.Equal(0, agg.Skipped); Assert.Equal(1, agg.Errors); @@ -491,7 +494,7 @@ public void ErrorStatusWithNullError_CountedAsError() (errorEntry, null), ]; - ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, [], filterSkipped: 0); + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, []); Assert.Equal(0, agg.Exported); Assert.Equal(0, agg.Skipped); @@ -513,8 +516,7 @@ public void IncludesScanErrorsAndNewErrors_InErrorMessages() ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults( results, - OneScanWarning, - filterSkipped: 0 + OneScanWarning ); Assert.Equal(2, agg.ErrorMessages.Count); diff --git a/CodeWalker.Cli/Tests/RpfHelperTests.cs b/CodeWalker.Cli/Tests/RpfHelperTests.cs index 291d9ad8b..a9c65c7a4 100644 --- a/CodeWalker.Cli/Tests/RpfHelperTests.cs +++ b/CodeWalker.Cli/Tests/RpfHelperTests.cs @@ -333,70 +333,6 @@ public void CollectFiles_Recursive_ReturnsCorrectRpfRef() Assert.Same(child, files[0].rpf); } - // --- CountNonRpfFiles --- - - [Fact] - public void CountNonRpfFiles_CountsCorrectly() - { - RpfFile rpf = new("test", "test.rpf", 0) - { - AllEntries = [MakeEntry("a.ydr"), MakeEntry("b.ytd")], - }; - Assert.Equal(2, RpfHelper.CountNonRpfFiles(rpf, recursive: false)); - } - - [Fact] - public void CountNonRpfFiles_SkipsRpfFiles() - { - RpfFile rpf = new("test", "test.rpf", 0) - { - AllEntries = [MakeEntry("nested.rpf"), MakeEntry("test.ydr")], - }; - Assert.Equal(1, RpfHelper.CountNonRpfFiles(rpf, recursive: false)); - } - - [Fact] - public void CountNonRpfFiles_Recursive() - { - RpfFile child = new("child", "child.rpf", 0) { AllEntries = [MakeEntry("b.ydr")] }; - RpfFile parent = new("parent", "parent.rpf", 0) - { - AllEntries = [MakeEntry("a.ydr")], - Children = [child], - }; - Assert.Equal(2, RpfHelper.CountNonRpfFiles(parent, recursive: true)); - } - - [Fact] - public void CountNonRpfFiles_NonRecursive_ExcludesChildren() - { - RpfFile child = new("child", "child.rpf", 0) { AllEntries = [MakeEntry("b.ydr")] }; - RpfFile parent = new("parent", "parent.rpf", 0) - { - AllEntries = [MakeEntry("a.ydr")], - Children = [child], - }; - Assert.Equal(1, RpfHelper.CountNonRpfFiles(parent, recursive: false)); - } - - [Fact] - public void CountNonRpfFiles_NullEntries_ReturnsZero() - { - RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = null }; - Assert.Equal(0, RpfHelper.CountNonRpfFiles(rpf, recursive: false)); - } - - [Fact] - public void CountNonRpfFiles_SkipsDirectoryEntries() - { - RpfDirectoryEntry dirEntry = new() { Name = "subdir", NameLower = "subdir", Path = "subdir" }; - RpfFile rpf = new("test", "test.rpf", 0) - { - AllEntries = [dirEntry, MakeEntry("a.ydr")], - }; - Assert.Equal(1, RpfHelper.CountNonRpfFiles(rpf, recursive: false)); - } - // --- ReportError --- private static Json.ExportResult MakeBaseResult(string[]? errors = null) => From 20469870f27f2a6ad343821c46146b22f0bf5d71 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:31:43 +0200 Subject: [PATCH 33/45] docs(cli): move the command reference into --help COMMANDS.md had drifted well away from the code: it described diff with a single --exe, listed --threads on commands that never had it, omitted search's --dir, and its sample output and hashes were invented. Rather than resynchronise a file that will drift again, the parts --help could not already express now live in the help output itself. Every command's help gains its --json field list and the exit codes, and the root gains the stdout/stderr split. System.CommandLine 2.0.2 keeps HelpBuilder and HelpContext internal, so CustomizeLayout is unreachable and HelpLayout replaces the help option's action instead. Two things the old document described correctly and the code did not: - hash reported its encoding as 'UTF8', which --encoding rejects. It now reports 'utf-8' and 'ascii', the spellings that parse. - search's patternType was pinned to 'substring' after glob detection was removed, so the JSON field and the '(substring)' suffix said nothing. --progress was defined four separate times with three different descriptions; it comes from CliOptions now, as --dry-run and --output already did. --output's default is expressed relatively so help shows '.' instead of whichever directory help happened to be run from. --- CodeWalker.Cli/Handlers/DiffHandler.cs | 6 +- CodeWalker.Cli/Handlers/Gen9Handler.cs | 6 +- CodeWalker.Cli/Handlers/HashHandler.cs | 15 +- CodeWalker.Cli/Handlers/PackHandler.cs | 6 +- CodeWalker.Cli/Handlers/SearchHandler.cs | 12 +- CodeWalker.Cli/Handlers/ValidateHandler.cs | 5 +- CodeWalker.Cli/Helpers/CliOptions.cs | 10 +- CodeWalker.Cli/Helpers/HelpLayout.cs | 146 ++++++++++++++++++ CodeWalker.Cli/Json/SearchResult.cs | 3 - CodeWalker.Cli/Program.cs | 3 + .../Tests/Handlers/HashHandlerTests.cs | 33 ++-- .../Tests/Handlers/SearchHandlerTests.cs | 26 +--- 12 files changed, 201 insertions(+), 70 deletions(-) create mode 100644 CodeWalker.Cli/Helpers/HelpLayout.cs diff --git a/CodeWalker.Cli/Handlers/DiffHandler.cs b/CodeWalker.Cli/Handlers/DiffHandler.cs index 9d534e3ef..009ec11c2 100644 --- a/CodeWalker.Cli/Handlers/DiffHandler.cs +++ b/CodeWalker.Cli/Handlers/DiffHandler.cs @@ -77,11 +77,7 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul Description = "Include nested RPFs in comparison", }; - Option progressOption = new("--progress", "-P") - { - Description = "Show progress bar", - }; - + Option progressOption = CliOptions.Progress(); Command command = new("diff", "Compare two RPF archives") { diff --git a/CodeWalker.Cli/Handlers/Gen9Handler.cs b/CodeWalker.Cli/Handlers/Gen9Handler.cs index 3596e563e..6ac51b0f6 100644 --- a/CodeWalker.Cli/Handlers/Gen9Handler.cs +++ b/CodeWalker.Cli/Handlers/Gen9Handler.cs @@ -65,11 +65,7 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul Description = "Don't copy files that don't need conversion", }; - Option progressOption = new("--progress", "-P") - { - Description = "Show progress bar", - }; - + Option progressOption = CliOptions.Progress(); Command command = new("gen9", "Convert files to enhanced (Gen9) format") { diff --git a/CodeWalker.Cli/Handlers/HashHandler.cs b/CodeWalker.Cli/Handlers/HashHandler.cs index 78914fd64..3f51ab598 100644 --- a/CodeWalker.Cli/Handlers/HashHandler.cs +++ b/CodeWalker.Cli/Handlers/HashHandler.cs @@ -17,7 +17,7 @@ internal sealed record HashOptions public required string Encoding { get; init; } public required bool Json { get; init; } - public const string DefaultEncoding = "UTF-8"; + public const string DefaultEncoding = "utf-8"; } internal static class HashHandler @@ -126,6 +126,17 @@ internal static JenkHashInputEncoding ParseEncoding(string encoding) => _ => throw new ArgumentException($"Unknown encoding: {encoding}. Use 'utf-8' or 'ascii'.") }; + /// + /// Spells an encoding the way --encoding accepts it, so reported values can be fed + /// straight back in. + /// + internal static string EncodingName(JenkHashInputEncoding encoding) => encoding switch + { + JenkHashInputEncoding.UTF8 => "utf-8", + JenkHashInputEncoding.ASCII => "ascii", + _ => encoding.ToString(), + }; + /// /// Collects the hash results for each input string and returns them as an array of objects. /// @@ -151,7 +162,7 @@ CancellationToken cancellationToken Hash = jenkHash.HashUint, HashSigned = jenkHash.HashInt, HashHex = jenkHash.HashHex, - Encoding = jenkHash.Encoding.ToString() + Encoding = EncodingName(jenkHash.Encoding) } ); } diff --git a/CodeWalker.Cli/Handlers/PackHandler.cs b/CodeWalker.Cli/Handlers/PackHandler.cs index eaa42e1aa..783d92c5b 100644 --- a/CodeWalker.Cli/Handlers/PackHandler.cs +++ b/CodeWalker.Cli/Handlers/PackHandler.cs @@ -54,11 +54,7 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul Description = "Overwrite existing output file", }; - Option progressOption = new("--progress", "-P") - { - Description = "Show progress bar", - }; - + Option progressOption = CliOptions.Progress(); Command command = new("pack", "Create an RPF archive from a directory of loose files") { diff --git a/CodeWalker.Cli/Handlers/SearchHandler.cs b/CodeWalker.Cli/Handlers/SearchHandler.cs index f2cd6e40e..a334b5d42 100644 --- a/CodeWalker.Cli/Handlers/SearchHandler.cs +++ b/CodeWalker.Cli/Handlers/SearchHandler.cs @@ -30,6 +30,7 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul { Option rpfOpt = CliOptions.Rpf(); rpfOpt.Required = false; + rpfOpt.Description = "Path to the RPF file; use --dir to search a folder instead"; Option exeOpt = CliOptions.Exe(); Option gen9Opt = CliOptions.Gen9(); Option filterOpt = CliOptions.Filter(); @@ -40,7 +41,7 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul Option dirOpt = new("--dir", "-D") { - Description = "Directory to search — discovers all .rpf files recursively", + Description = "Directory to search; every .rpf below it is searched", }; Argument patternArg = new("pattern") @@ -48,7 +49,7 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul Description = "Substring to search for in file paths", }; - Command command = new("search", "Search for files by name or path in an RPF archive") + Command command = new("search", "Search for files by name or path in one or more RPF archives") { patternArg, rpfOpt, @@ -230,7 +231,6 @@ internal static int ExecuteDirectory(SearchOptions options, CancellationToken ca RpfFile = options.DirPath!, RpfFiles = rpfFiles, Pattern = options.Pattern, - PatternType = "substring", MatchCount = allMatches.Count, Matches = allMatches, ErrorMessages = [.. allScanErrors], @@ -251,7 +251,6 @@ internal static Json.SearchResult ErrorResult(string[] errorMessages, SearchOpti RpfFile = options.RpfPath, RpfFiles = [], Pattern = options.Pattern, - PatternType = "substring", MatchCount = 0, Matches = [], ErrorMessages = errorMessages, @@ -310,7 +309,6 @@ internal static Json.SearchResult CollectSearch( RpfFile = archivePath, RpfFiles = [archivePath], Pattern = options.Pattern, - PatternType = "substring", MatchCount = matches.Count, Matches = matches, ErrorMessages = [.. scanErrors], @@ -352,8 +350,8 @@ internal static void PrintSearch(Json.SearchResult result, SearchOptions options Console.Error.WriteLine(); Console.Error.WriteLine( multiArchive - ? $"Found {result.MatchCount} {matchWord} across {result.RpfFiles.Count} archive(s) for '{result.Pattern}' ({result.PatternType})" - : $"Found {result.MatchCount} {matchWord} for '{result.Pattern}' ({result.PatternType})" + ? $"Found {result.MatchCount} {matchWord} across {result.RpfFiles.Count} archive(s) for '{result.Pattern}'" + : $"Found {result.MatchCount} {matchWord} for '{result.Pattern}'" ); } diff --git a/CodeWalker.Cli/Handlers/ValidateHandler.cs b/CodeWalker.Cli/Handlers/ValidateHandler.cs index 3d078cae5..017c089ec 100644 --- a/CodeWalker.Cli/Handlers/ValidateHandler.cs +++ b/CodeWalker.Cli/Handlers/ValidateHandler.cs @@ -39,10 +39,7 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul Option jsonOpt = CliOptions.Json(); Option siOpt = CliOptions.Si(); Option threadsOpt = CliOptions.Threads(); - Option progressOpt = new("--progress", "-P") - { - Description = "Show progress bar during validation", - }; + Option progressOpt = CliOptions.Progress(); Command command = new("validate", "Validate game file integrity by parsing RPF contents") { diff --git a/CodeWalker.Cli/Helpers/CliOptions.cs b/CodeWalker.Cli/Helpers/CliOptions.cs index 554aa3b57..31ca1d91e 100644 --- a/CodeWalker.Cli/Helpers/CliOptions.cs +++ b/CodeWalker.Cli/Helpers/CliOptions.cs @@ -6,14 +6,12 @@ namespace CodeWalker.Cli.Helpers; internal static class CliOptions { - // From RpfCommandOptions: public static Option Rpf() => new("--rpf", "-r") { Description = "Path to the RPF file", Required = true, }; - // From CommonCommandOptions: public static Option Exe(bool required = true) => new("--exe", "-e") { Description = "Path to the GTA V installation directory (containing GTA5.exe)", @@ -66,16 +64,16 @@ public static Option Threads() return opt; } - // From ExportCommandOptions: public static Option OutputDir() => new("--output", "-o") { Description = "Output directory", - DefaultValueFactory = _ => new DirectoryInfo(Directory.GetCurrentDirectory()), + // Relative so help shows "." rather than whichever directory help was run from. + DefaultValueFactory = _ => new DirectoryInfo("."), }; public static Option DryRun() => new("--dry-run", "-n") { - Description = "Show what would be exported without writing files", + Description = "Show what would be done without writing any files", }; public static Option NoOverwrite() => new("--no-overwrite") @@ -85,6 +83,6 @@ public static Option Threads() public static Option Progress() => new("--progress", "-P") { - Description = "Show progress bar during export", + Description = "Show a progress bar", }; } diff --git a/CodeWalker.Cli/Helpers/HelpLayout.cs b/CodeWalker.Cli/Helpers/HelpLayout.cs new file mode 100644 index 000000000..c56c45d17 --- /dev/null +++ b/CodeWalker.Cli/Helpers/HelpLayout.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Help; +using System.CommandLine.Invocation; +using System.IO; +using System.Linq; + +namespace CodeWalker.Cli.Helpers; + +/// +/// Appends the parts of the command reference that generated option tables cannot carry: +/// what each output stream is for, the shape of --json output, and the exit codes. +/// +/// +/// System.CommandLine keeps its help layout API internal, so the help option's action is +/// wrapped rather than reconfigured: the stock help is written first, then these sections. +/// +internal sealed class HelpLayout : SynchronousCommandLineAction +{ + private const string ExportFields = + "rpfFile, outputDir, format, totalFiles, exported, skipped, errors, dryRun, " + + "files[{path, name, outputPath, outputFiles, status}]"; + + /// + /// Fields each command writes under --json, keyed by command name, on top of the + /// success and errorMessages that every result carries. + /// + private static readonly Dictionary JsonFields = new(StringComparer.Ordinal) + { + ["extract"] = "rpfFile, outputDir, totalFiles, extracted, skipped, errors, dryRun, " + + "files[{path, name, size, sizeFormatted, type, extension, status}]", + ["list"] = "rpfFile, totalFiles, totalSize, totalSizeFormatted, nestedRpfCount, " + + "files[{path, name, size, sizeFormatted, type, extension}]", + ["hash"] = "hashes[{input, hash, hashSigned, hashHex, encoding}]", + ["tree"] = "rpfFile, totalFiles, totalDirs, " + + "root{name, path, type, size, sizeFormatted, fileType, version, children[]}", + ["gen9"] = "inputFolder, outputFolder, totalFiles, converted, skipped, copied, errors, " + + "files[{path, status, message}]", + ["pack"] = "inputDir, outputFile, totalFiles, totalDirs, totalSize, " + + "totalSizeFormatted, errors", + ["diff"] = "leftRpf, rightRpf, added[], removed[], modified[], unchanged[], " + + "summary{addedCount, removedCount, modifiedCount, unchangedCount}", + ["stat"] = "rpfFile, totalFiles, totalSize, totalSizeFormatted, resourceCount, " + + "binaryCount, compressedSize, uncompressedSize, compressionRatio, " + + "extensions[{extension, count, totalSize, avgSize, minSize, maxSize}]", + ["search"] = "rpfFile, rpfFiles, pattern, matchCount, " + + "matches[{archive, path, name, size, type, extension}]", + ["validate"] = "rpfFile, totalFiles, valid, warnings, errors, skipped, " + + "files[{path, name, status, message}]", + ["inspect"] = "rpfFile, path, name, size, sizeFormatted, type, extension, nameHash, " + + "shortNameHash, resourceVersion, systemSize, graphicsSize, uncompressedSize, " + + "encryptionType, details (shape depends on the file type)", + ["xml"] = ExportFields, + ["textures"] = ExportFields, + ["audio"] = ExportFields, + ["text"] = ExportFields, + }; + + private readonly HelpAction inner = new(); + + /// + /// Replaces the help action on . The option is recursive, so every + /// subcommand's help goes through it too. + /// + public static void Install(Command root) + { + HelpOption? help = root.Options.OfType().FirstOrDefault(); + if (help != null) + help.Action = new HelpLayout(); + } + + public override int Invoke(ParseResult parseResult) + { + int result = this.inner.Invoke(parseResult); + + TextWriter output = parseResult.InvocationConfiguration.Output; + Command command = parseResult.CommandResult.Command; + // MaxWidth is unbounded when stdout is not a terminal; keep prose readable anyway. + int width = Math.Min(100, Math.Max(40, this.inner.MaxWidth)); + + if (command.Parents.Any() == false) + WriteStreams(output); + + WriteJsonFields(output, command.Name, width); + WriteExitCodes(output); + + return result; + } + + private static void WriteStreams(TextWriter output) + { + output.WriteLine("Output:"); + output.WriteLine(" stdout Data: file listings, trees, hashes, JSON."); + output.WriteLine(" stderr Progress, status and error messages."); + output.WriteLine(); + output.WriteLine(" Redirecting stdout captures the data alone, so a run stays pipeable"); + output.WriteLine(" while still reporting what it is doing."); + output.WriteLine(); + } + + private static void WriteJsonFields(TextWriter output, string commandName, int width) + { + if (!JsonFields.TryGetValue(commandName, out string? fields)) + return; + + output.WriteLine("JSON output (--json):"); + output.WriteLine(" A single object on stdout. Always present:"); + output.WriteLine(" success Whether the command completed without errors."); + output.WriteLine(" errorMessages Every error encountered, as an array."); + output.WriteLine(); + output.WriteLine(" Alongside those:"); + foreach (string line in Wrap(fields, width - 4)) + output.WriteLine(" " + line); + output.WriteLine(); + } + + private static void WriteExitCodes(TextWriter output) + { + output.WriteLine("Exit codes:"); + output.WriteLine(" 0 Success."); + output.WriteLine(" 1 One or more errors, or invalid arguments."); + output.WriteLine(" 130 Cancelled with Ctrl+C."); + } + + internal static List Wrap(string text, int width) + { + List lines = []; + string current = ""; + foreach (string word in text.Split(' ')) + { + if (current.Length == 0) + current = word; + else if (current.Length + 1 + word.Length <= width) + current += " " + word; + else + { + lines.Add(current); + current = word; + } + } + if (current.Length > 0) + lines.Add(current); + return lines; + } +} diff --git a/CodeWalker.Cli/Json/SearchResult.cs b/CodeWalker.Cli/Json/SearchResult.cs index 97479426f..f22c053cf 100644 --- a/CodeWalker.Cli/Json/SearchResult.cs +++ b/CodeWalker.Cli/Json/SearchResult.cs @@ -40,9 +40,6 @@ internal sealed record SearchResult : BaseResult [JsonPropertyName("pattern")] public required string Pattern { get; init; } - [JsonPropertyName("patternType")] - public required string PatternType { get; init; } - [JsonPropertyName("matchCount")] public required int MatchCount { get; init; } diff --git a/CodeWalker.Cli/Program.cs b/CodeWalker.Cli/Program.cs index 171e345d4..2ee7eb34a 100644 --- a/CodeWalker.Cli/Program.cs +++ b/CodeWalker.Cli/Program.cs @@ -3,6 +3,7 @@ using System.Threading; using CodeWalker.Cli.Handlers; +using CodeWalker.Cli.Helpers; using CancellationTokenSource cts = new(); Console.CancelKeyPress += (_, e) => @@ -27,6 +28,8 @@ InspectHandler.CreateCommand(cts.Token), }; +HelpLayout.Install(rootCommand); + try { return rootCommand.Parse(args).Invoke(); diff --git a/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs index 6966f33ee..cd56f28cf 100644 --- a/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs @@ -25,6 +25,19 @@ public sealed class ParseEncodingTests public void ValidEncoding_ReturnsExpected(string input, JenkHashInputEncoding expected) => Assert.Equal(expected, HashHandler.ParseEncoding(input)); + [Theory] + [InlineData(JenkHashInputEncoding.UTF8, "utf-8")] + [InlineData(JenkHashInputEncoding.ASCII, "ascii")] + public void EncodingName_RoundTripsThroughParseEncoding( + JenkHashInputEncoding encoding, + string expected + ) + { + string name = HashHandler.EncodingName(encoding); + Assert.Equal(expected, name); + Assert.Equal(encoding, HashHandler.ParseEncoding(name)); + } + [Theory] [InlineData("utf8")] [InlineData("latin-1")] @@ -84,7 +97,7 @@ private static string Capture(string[] inputs, JenkHashInputEncoding encoding) public void SingleInput_PrintsAllFourLines() { string output = Capture(["test"], JenkHashInputEncoding.UTF8); - Assert.Contains("Input (UTF8): test", output); + Assert.Contains("Input (utf-8): test", output); Assert.Contains("Hash (uint):", output); Assert.Contains("Hash (int):", output); Assert.Contains("Hash (hex):", output); @@ -104,22 +117,22 @@ public void SingleInput_MatchesJenkHash() public void AsciiEncoding_ShowsAsciiInHeader() { string output = Capture(["hello"], JenkHashInputEncoding.ASCII); - Assert.Contains("Input (ASCII): hello", output); + Assert.Contains("Input (ascii): hello", output); } [Fact] public void MultipleInputs_PrintsEach() { string output = Capture(["alpha", "bravo"], JenkHashInputEncoding.UTF8); - Assert.Contains("Input (UTF8): alpha", output); - Assert.Contains("Input (UTF8): bravo", output); + Assert.Contains("Input (utf-8): alpha", output); + Assert.Contains("Input (utf-8): bravo", output); } [Fact] public void EmptyString_Succeeds() { string output = Capture([""], JenkHashInputEncoding.UTF8); - Assert.Contains("Input (UTF8): ", output); + Assert.Contains("Input (utf-8): ", output); Assert.Contains("Hash (uint):", output); } @@ -194,7 +207,7 @@ public void SingleInput_MatchesJenkHash() Assert.Equal(expected.HashUint, entry.Hash); Assert.Equal(expected.HashInt, entry.HashSigned); Assert.Equal(expected.HashHex, entry.HashHex); - Assert.Equal("UTF8", entry.Encoding); + Assert.Equal("utf-8", entry.Encoding); } [Fact] @@ -205,7 +218,7 @@ public void AsciiEncoding_SetsEncodingField() Output.JsonSerializerOptions ); Assert.NotNull(result); - Assert.Equal("ASCII", result.Hashes[0].Encoding); + Assert.Equal("ascii", result.Hashes[0].Encoding); } [Fact] @@ -252,7 +265,7 @@ public void SingleInput_MatchesJenkHash() Assert.Equal(expected.HashUint, entry.Hash); Assert.Equal(expected.HashInt, entry.HashSigned); Assert.Equal(expected.HashHex, entry.HashHex); - Assert.Equal("UTF8", entry.Encoding); + Assert.Equal("utf-8", entry.Encoding); } [Fact] @@ -263,7 +276,7 @@ public void AsciiEncoding_SetsEncodingField() JenkHashInputEncoding.ASCII, TestContext.Current.CancellationToken ); - Assert.Equal("ASCII", entries[0].Encoding); + Assert.Equal("ascii", entries[0].Encoding); } [Fact] @@ -421,7 +434,7 @@ public void Json_InvalidEncoding_ReturnsJsonError() [Fact] public void DefaultEncoding_IsUtf8() => - Assert.Equal("UTF-8", HashOptions.DefaultEncoding); + Assert.Equal("utf-8", HashOptions.DefaultEncoding); [Fact] public void Text_Cancelled_ThrowsOperationCanceledException() diff --git a/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs index 844cd0295..1c32a72a1 100644 --- a/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs @@ -50,13 +50,6 @@ public void ErrorResult_PreservesPattern() Assert.Equal("adder", result.Pattern); } - [Fact] - public void ErrorResult_SetsPatternTypeSubstring() - { - Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(pattern: "*.ydr")); - Assert.Equal("substring", result.PatternType); - } - [Fact] public void ErrorResult_SetsMatchCountZero() { @@ -165,7 +158,6 @@ public void CollectSearch_SubstringMatch_FindsEntries() Assert.True(result.Success); Assert.Equal(2, result.MatchCount); - Assert.Equal("substring", result.PatternType); } [Fact] @@ -183,7 +175,6 @@ public void CollectSearch_ExtensionSubstring_FindsEntries() Assert.True(result.Success); Assert.Equal(2, result.MatchCount); - Assert.Equal("substring", result.PatternType); } [Fact] @@ -566,7 +557,6 @@ private static SearchOptions MakeOptions(bool verbose = false, SizeFormat sizeFo private static Json.SearchResult MakeResult( List? matches = null, string pattern = "*.ydr", - string patternType = "glob", int? matchCount = null, IReadOnlyList? rpfFiles = null) => new() @@ -575,7 +565,6 @@ private static Json.SearchResult MakeResult( RpfFile = "/test.rpf", RpfFiles = rpfFiles ?? ["/test.rpf"], Pattern = pattern, - PatternType = patternType, MatchCount = matchCount ?? matches?.Count ?? 0, Matches = matches ?? [], ErrorMessages = [], @@ -668,14 +657,12 @@ public void PrintSearch_PrintsSummaryToStderr() Json.SearchResult result = MakeResult( [MakeMatch()], - pattern: "adder", - patternType: "substring"); + pattern: "adder"); SearchHandler.PrintSearch(result, MakeOptions()); string errOutput = stderr.ToString(); Assert.Contains("Found 1 match for", errOutput); Assert.Contains("'adder'", errOutput); - Assert.Contains("(substring)", errOutput); } finally { @@ -696,7 +683,7 @@ public void PrintSearch_EmptyResults_PrintsSummaryOnly() Console.SetOut(stdout); Console.SetError(stderr); - Json.SearchResult result = MakeResult(pattern: "nothing", patternType: "substring"); + Json.SearchResult result = MakeResult(pattern: "nothing"); SearchHandler.PrintSearch(result, MakeOptions()); Assert.Equal("", stdout.ToString()); @@ -778,14 +765,12 @@ public void PrintJsonSearch_OutputsValidJson() Json.SearchResult result = MakeResult( [MakeMatch()], - pattern: "adder", - patternType: "substring"); + pattern: "adder"); SearchHandler.PrintJsonSearch(result); string output = stdout.ToString(); Assert.Contains("\"success\": true", output); Assert.Contains("\"pattern\": \"adder\"", output); - Assert.Contains("\"patternType\": \"substring\"", output); Assert.Contains("\"matchCount\": 1", output); Assert.Contains("\"path\": \"vehicles/adder.ydr\"", output); } @@ -1110,7 +1095,6 @@ public void PrintSearch_MultiRpf_GroupsByArchive() RpfFile = "/dir", RpfFiles = ["/dir/a.rpf", "/dir/b.rpf"], Pattern = "*.ydr", - PatternType = "glob", MatchCount = 2, Matches = [ @@ -1156,7 +1140,6 @@ public void PrintSearch_MultiRpf_SummaryIncludesArchiveCount() RpfFile = "/dir", RpfFiles = ["/dir/a.rpf", "/dir/b.rpf"], Pattern = "adder", - PatternType = "substring", MatchCount = 3, Matches = [ @@ -1172,7 +1155,6 @@ public void PrintSearch_MultiRpf_SummaryIncludesArchiveCount() string errOutput = stderr.ToString(); Assert.Contains("Found 3 matches across 2 archive(s)", errOutput); Assert.Contains("'adder'", errOutput); - Assert.Contains("(substring)", errOutput); } finally { @@ -1199,7 +1181,6 @@ public void PrintSearch_SingleRpf_NoArchiveHeaders() RpfFile = "/test.rpf", RpfFiles = ["/test.rpf"], Pattern = "*.ydr", - PatternType = "glob", MatchCount = 1, Matches = [ @@ -1240,7 +1221,6 @@ public void PrintSearch_MultiRpf_Verbose_ShowsSizeAndArchiveHeaders() RpfFile = "/dir", RpfFiles = ["/dir/a.rpf", "/dir/b.rpf"], Pattern = "*.ydr", - PatternType = "glob", MatchCount = 2, Matches = [ From 0c6e83fdf5dcac1ba66240c48077256af8de8739 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:39:28 +0200 Subject: [PATCH 34/45] style(cli): normalize comments and doc tags No behaviour change. HashHandler and StatHandler carried full param/returns/exception blocks that restated their signatures, while the other fourteen files used a one-line summary or nothing. They now match. The same goes for the param tags on Filter and SizeFormat; ProgressBar and ExportPipeline keep theirs, where the text describes contract details the names do not. Comments that repeated the statement below them are gone, along with the decorative rules separating test sections. The box-drawing characters in TreeHandlerTests stay: they quote the glyphs the command actually emits. The cancellation rethrow is written the same way in every handler now, without the comment that was copied alongside it five times. csharp_style_var_* was already set to prefer explicit types but only at suggestion level, so nothing enforced it. Raised to warning; no code changes needed, the tree already complies. --- CodeWalker.Cli/.editorconfig | 6 +-- CodeWalker.Cli/Handlers/DiffHandler.cs | 1 - CodeWalker.Cli/Handlers/Gen9Handler.cs | 1 - CodeWalker.Cli/Handlers/HashHandler.cs | 34 ++++------------- CodeWalker.Cli/Handlers/InspectHandler.cs | 2 - CodeWalker.Cli/Handlers/ListHandler.cs | 6 +-- CodeWalker.Cli/Handlers/PackHandler.cs | 2 - CodeWalker.Cli/Handlers/SearchHandler.cs | 7 +--- CodeWalker.Cli/Handlers/StatHandler.cs | 38 ++++--------------- CodeWalker.Cli/Handlers/TreeHandler.cs | 7 +--- CodeWalker.Cli/Handlers/ValidateHandler.cs | 1 - CodeWalker.Cli/Helpers/Filter.cs | 9 +---- CodeWalker.Cli/Helpers/HelpLayout.cs | 6 +-- CodeWalker.Cli/Helpers/SizeFormat.cs | 3 -- .../Tests/Handlers/DiffHandlerTests.cs | 4 +- .../Tests/Handlers/ExtractHandlerTests.cs | 2 +- .../Tests/Handlers/Gen9HandlerTests.cs | 8 ++-- .../Tests/Handlers/HashHandlerTests.cs | 12 +++--- .../Tests/Handlers/InspectHandlerTests.cs | 4 +- .../Tests/Handlers/ListHandlerTests.cs | 10 ++--- .../Tests/Handlers/PackHandlerTests.cs | 8 ++-- .../Tests/Handlers/SearchHandlerTests.cs | 26 ++++++------- .../Tests/Handlers/StatHandlerTests.cs | 20 +++++----- .../Tests/Handlers/TreeHandlerTests.cs | 18 ++++----- .../Tests/Handlers/ValidateHandlerTests.cs | 2 +- .../Tests/Helpers/ProgressBarTests.cs | 34 ++++++++--------- CodeWalker.Cli/Tests/PolyfillsTests.cs | 14 +++---- 27 files changed, 105 insertions(+), 180 deletions(-) diff --git a/CodeWalker.Cli/.editorconfig b/CodeWalker.Cli/.editorconfig index 41be91a32..7c87d9801 100644 --- a/CodeWalker.Cli/.editorconfig +++ b/CodeWalker.Cli/.editorconfig @@ -81,9 +81,9 @@ dotnet_remove_unnecessary_suppression_exclusions = none [*.cs] # var preferences -csharp_style_var_elsewhere = false:suggestion -csharp_style_var_for_built_in_types = false:suggestion -csharp_style_var_when_type_is_apparent = false:suggestion +csharp_style_var_elsewhere = false:warning +csharp_style_var_for_built_in_types = false:warning +csharp_style_var_when_type_is_apparent = false:warning # Expression-bodied members csharp_style_expression_bodied_accessors = true:suggestion diff --git a/CodeWalker.Cli/Handlers/DiffHandler.cs b/CodeWalker.Cli/Handlers/DiffHandler.cs index 009ec11c2..0591bb8d3 100644 --- a/CodeWalker.Cli/Handlers/DiffHandler.cs +++ b/CodeWalker.Cli/Handlers/DiffHandler.cs @@ -123,7 +123,6 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul public static int Execute(DiffOptions options, CancellationToken cancellationToken = default) { - // Validate both RPF files and exe paths before loading keys string? leftError = RpfHelper.ValidateInputs( options.LeftPath, options.LeftExePath, diff --git a/CodeWalker.Cli/Handlers/Gen9Handler.cs b/CodeWalker.Cli/Handlers/Gen9Handler.cs index 6ac51b0f6..934a26bae 100644 --- a/CodeWalker.Cli/Handlers/Gen9Handler.cs +++ b/CodeWalker.Cli/Handlers/Gen9Handler.cs @@ -340,7 +340,6 @@ out bool wasConverted } ); - // Aggregate non-RPF results foreach ((Json.Gen9FileEntry entry, string? error) in nonRpfResults) { files.Add(entry); diff --git a/CodeWalker.Cli/Handlers/HashHandler.cs b/CodeWalker.Cli/Handlers/HashHandler.cs index 3f51ab598..eb4004c7b 100644 --- a/CodeWalker.Cli/Handlers/HashHandler.cs +++ b/CodeWalker.Cli/Handlers/HashHandler.cs @@ -65,12 +65,8 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul } /// - /// Executes the hash generation based on the provided options. - /// It handles both human-readable and JSON output formats, and gracefully manages cancellation and errors. + /// Hashes every input and prints the results. /// - /// The options containing the input strings, encoding, and output format preferences. - /// A cancellation token to observe while performing the hashing operation. - /// An integer exit code indicating success (0) or failure (1). public static int Execute(HashOptions options, CancellationToken cancellationToken = default) { try @@ -84,11 +80,7 @@ public static int Execute(HashOptions options, CancellationToken cancellationTok return 0; } - catch (OperationCanceledException) - { - // Gracefully handle cancellation without printing an error message - throw; - } + catch (OperationCanceledException) { throw; } catch (Exception ex) { return Output.ReportError( @@ -100,10 +92,8 @@ public static int Execute(HashOptions options, CancellationToken cancellationTok } /// - /// Creates a JSON result object representing an error, with the provided error messages. + /// A failed result carrying the given messages. /// - /// An array of error messages to include in the result. - /// A object with success set to false and the provided error messages. internal static Json.HashResult ErrorResult(string[] errorMessages) => new() { @@ -113,11 +103,8 @@ internal static Json.HashResult ErrorResult(string[] errorMessages) => }; /// - /// Parses the encoding string into a enum value. + /// Parses an encoding name, throwing if it is not recognised. /// - /// The encoding string to parse (e.g., "utf-8", "ascii"). - /// The corresponding value. - /// Thrown if the encoding string is not recognized. internal static JenkHashInputEncoding ParseEncoding(string encoding) => encoding.ToUpperInvariant() switch { @@ -138,12 +125,8 @@ internal static JenkHashInputEncoding ParseEncoding(string encoding) => }; /// - /// Collects the hash results for each input string and returns them as an array of objects. + /// Hashes each input under the given encoding. /// - /// An array of input strings to hash. - /// The encoding to use for hashing the input strings. - /// A cancellation token to observe while performing the hashing operation. - /// An array of objects containing the hash results for each input string. internal static Json.HashEntry[] CollectHashes( string[] inputs, JenkHashInputEncoding encoding, @@ -171,9 +154,8 @@ CancellationToken cancellationToken } /// - /// Prints the hash results to the console in JSON format, including the input, hash values, and encoding used. + /// Prints the hashes as JSON. /// - /// An array of pre-computed hash entries to serialize. internal static void PrintJsonHashes(Json.HashEntry[] hashes) { Json.HashResult result = new() @@ -186,10 +168,8 @@ internal static void PrintJsonHashes(Json.HashEntry[] hashes) } /// - /// Prints the hash results to the console in a human-readable format. + /// Prints the hashes one block per input. /// - /// An array of pre-computed hash entries to print. - /// A cancellation token to observe while printing. internal static void PrintHashes( Json.HashEntry[] entries, CancellationToken cancellationToken diff --git a/CodeWalker.Cli/Handlers/InspectHandler.cs b/CodeWalker.Cli/Handlers/InspectHandler.cs index 044e3561b..34c0459e6 100644 --- a/CodeWalker.Cli/Handlers/InspectHandler.cs +++ b/CodeWalker.Cli/Handlers/InspectHandler.cs @@ -127,7 +127,6 @@ Json.InspectResult ErrorResult(string[] errorMessages) => Console.Error.WriteLine(); } - // Find entry by normalized path string normalizedPath = options.FilePath.Replace('\\', '/'); RpfFileEntry? found = FindEntry(rpf, normalizedPath, options.Recursive); @@ -144,7 +143,6 @@ Json.InspectResult ErrorResult(string[] errorMessages) => string ext = Path.GetExtension(found.Name).ToLowerInvariant(); string fileType = RpfHelper.GetFileType(found); - // Extract type-specific metadata int? resourceVersion = null; long? systemSize = null; long? graphicsSize = null; diff --git a/CodeWalker.Cli/Handlers/ListHandler.cs b/CodeWalker.Cli/Handlers/ListHandler.cs index be659ee1a..dd25af865 100644 --- a/CodeWalker.Cli/Handlers/ListHandler.cs +++ b/CodeWalker.Cli/Handlers/ListHandler.cs @@ -112,11 +112,7 @@ public static int Execute(ListOptions options, CancellationToken cancellationTok return scanErrors.Count > 0 ? 1 : 0; } - catch (OperationCanceledException) - { - // Gracefully handle cancellation without printing an error message - throw; - } + catch (OperationCanceledException) { throw; } catch (Exception ex) { return Output.ReportError( diff --git a/CodeWalker.Cli/Handlers/PackHandler.cs b/CodeWalker.Cli/Handlers/PackHandler.cs index 783d92c5b..552ebb1d7 100644 --- a/CodeWalker.Cli/Handlers/PackHandler.cs +++ b/CodeWalker.Cli/Handlers/PackHandler.cs @@ -158,7 +158,6 @@ Json.PackResult ErrorResult(string[] errorMessages) => ); } - // Create the output directory if needed string? outputDir = Path.GetDirectoryName(options.OutputPath); if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir)) { @@ -308,7 +307,6 @@ CancellationToken cancellationToken } } - // Add files foreach (string filePath in Directory.GetFiles(fsDir)) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/CodeWalker.Cli/Handlers/SearchHandler.cs b/CodeWalker.Cli/Handlers/SearchHandler.cs index a334b5d42..eb0fbb9ee 100644 --- a/CodeWalker.Cli/Handlers/SearchHandler.cs +++ b/CodeWalker.Cli/Handlers/SearchHandler.cs @@ -135,11 +135,7 @@ public static int Execute(SearchOptions options, CancellationToken cancellationT return scanErrors.Count > 0 ? 1 : 0; } - catch (OperationCanceledException) - { - // Gracefully handle cancellation without printing an error message - throw; - } + catch (OperationCanceledException) { throw; } catch (Exception ex) { return Output.ReportError( @@ -357,7 +353,6 @@ internal static void PrintSearch(Json.SearchResult result, SearchOptions options internal static string RelativePath(string basePath, string fullPath) { - // Normalize separators and ensure trailing separator on base string normalizedBase = basePath.Replace('\\', '/').TrimEnd('/') + "/"; string normalizedFull = fullPath.Replace('\\', '/'); diff --git a/CodeWalker.Cli/Handlers/StatHandler.cs b/CodeWalker.Cli/Handlers/StatHandler.cs index 8190efec5..0cc2c73e6 100644 --- a/CodeWalker.Cli/Handlers/StatHandler.cs +++ b/CodeWalker.Cli/Handlers/StatHandler.cs @@ -70,11 +70,8 @@ public static Command CreateCommand(CancellationToken cancellationToken = defaul } /// - /// Executes the stat command by validating the RPF file, collecting file entries, calculating statistics, and printing the results in either JSON or human-readable format. + /// Validates the archive, collects statistics for the matching entries and prints them. /// - /// The options for the stat command, including the RPF file path, filters, and output format. - /// A cancellation token to observe while performing the operation. - /// An integer exit code indicating success (0) or failure (1). public static int Execute(StatOptions options, CancellationToken cancellationToken = default) { string? initError = RpfHelper.ValidateAndLoadKeys( @@ -120,11 +117,7 @@ public static int Execute(StatOptions options, CancellationToken cancellationTok return scanErrors.Count > 0 ? 1 : 0; } - catch (OperationCanceledException) - { - // Gracefully handle cancellation without printing an error message - throw; - } + catch (OperationCanceledException) { throw; } catch (Exception ex) { return Output.ReportError( @@ -137,11 +130,8 @@ public static int Execute(StatOptions options, CancellationToken cancellationTok } /// - /// Creates a JSON result object representing an error, with the provided error messages and default values for all statistics fields. + /// A failed result carrying the given messages, with every statistic zeroed. /// - /// An array of error messages to include in the result. - /// The options used to populate the RpfFile field in the result. - /// A object with success set to false, the RpfFile field set from options, and all statistics fields set to default values. internal static Json.StatResult ErrorResult(string[] errorMessages, StatOptions options) => new() { @@ -162,13 +152,8 @@ internal static Json.StatResult ErrorResult(string[] errorMessages, StatOptions }; /// - /// Collects the statistics for the given list of RPF file entries, including total size, file counts, compression ratios, and extension-based statistics, and returns the results in a object. + /// Totals, compression figures and per-extension breakdown for the given entries. /// - /// A list of tuples containing the RPF file and its corresponding file entry to analyze for statistics. - /// A list of error messages encountered during the scanning process, which will be included in the result. - /// The options used to populate the RpfFile field in the result and format size values. - /// A cancellation token to observe while performing the statistics collection operation. - /// A object containing the collected statistics for the RPF file entries, including total size, file counts, compression ratios, extension-based statistics, and any error messages. internal static Json.StatResult CollectStats( List<(RpfFile rpf, RpfFileEntry entry)> entries, List scanErrors, @@ -274,18 +259,14 @@ .. extStats } /// - /// Prints the collected statistics to the console in JSON format. + /// Prints the statistics as JSON. /// - /// The collected statistics to serialize. internal static void PrintJsonStats(Json.StatResult result) => Console.WriteLine(JsonSerializer.Serialize(result, Output.JsonSerializerOptions)); /// - /// Prints the collected statistics to the console in a human-readable format. + /// Prints the statistics as an aligned table. /// - /// The collected statistics to print. - /// The options used to format size values in the output. - /// A cancellation token to observe while printing. internal static void PrintStats(Json.StatResult result, StatOptions options, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -305,7 +286,6 @@ .. result.Extensions ) ]; - // Calculate column widths from headers and data int[] widths = new int[headers.Length]; for (int i = 0; i < headers.Length; i++) widths[i] = headers[i].Length; @@ -314,25 +294,22 @@ .. result.Extensions for (int i = 0; i < row.Length; i++) widths[i] = Math.Max(widths[i], row[i].Length); - // Top border Console.Write($"+{new string('-', widths[0] + 2)}"); for (int i = 1; i < widths.Length; i++) Console.Write($"+{new string('-', widths[i] + 2)}"); Console.WriteLine("+"); - // Header — first column left-aligned, rest right-aligned + // First column left-aligned, the rest right-aligned Console.Write($"| {headers[0].PadRight(widths[0])} "); for (int i = 1; i < headers.Length; i++) Console.Write($"| {headers[i].PadLeft(widths[i])} "); Console.WriteLine("|"); - // Separator Console.Write($"+{new string('-', widths[0] + 2)}"); for (int i = 1; i < widths.Length; i++) Console.Write($"+{new string('-', widths[i] + 2)}"); Console.WriteLine("+"); - // Data rows foreach (string[] row in rows) { Console.Write($"| {row[0].PadRight(widths[0])} "); @@ -341,7 +318,6 @@ .. result.Extensions Console.WriteLine("|"); } - // Bottom border Console.Write($"+{new string('-', widths[0] + 2)}"); for (int i = 1; i < widths.Length; i++) Console.Write($"+{new string('-', widths[i] + 2)}"); diff --git a/CodeWalker.Cli/Handlers/TreeHandler.cs b/CodeWalker.Cli/Handlers/TreeHandler.cs index e58ebcc8c..a347f9086 100644 --- a/CodeWalker.Cli/Handlers/TreeHandler.cs +++ b/CodeWalker.Cli/Handlers/TreeHandler.cs @@ -134,11 +134,7 @@ public static int Execute(TreeOptions options, CancellationToken cancellationTok return scanErrors.Count > 0 ? 1 : 0; } - catch (OperationCanceledException) - { - // Gracefully handle cancellation without printing an error message - throw; - } + catch (OperationCanceledException) { throw; } catch (Exception ex) { return Output.ReportError( @@ -166,7 +162,6 @@ internal static List CollectChildren(RpfDirectoryEntry dir, RpfFile r List items = []; HashSet expandedRpfs = new(StringComparer.Ordinal); - // Add subdirectories if (dir.Directories != null) { foreach (RpfDirectoryEntry subDir in dir.Directories) diff --git a/CodeWalker.Cli/Handlers/ValidateHandler.cs b/CodeWalker.Cli/Handlers/ValidateHandler.cs index 017c089ec..37b1df027 100644 --- a/CodeWalker.Cli/Handlers/ValidateHandler.cs +++ b/CodeWalker.Cli/Handlers/ValidateHandler.cs @@ -194,7 +194,6 @@ Json.ValidateResult ErrorResult(string[] errorMessages) => ); } - // Aggregate results List nonNull = results.OfType().ToList(); int valid = nonNull.Count(e => e.Status == "valid"); int warnings = nonNull.Count(e => e.Status == "warning"); diff --git a/CodeWalker.Cli/Helpers/Filter.cs b/CodeWalker.Cli/Helpers/Filter.cs index 76730fd5b..59b63b002 100644 --- a/CodeWalker.Cli/Helpers/Filter.cs +++ b/CodeWalker.Cli/Helpers/Filter.cs @@ -15,8 +15,6 @@ internal static class Filter /// /// Normalizes filter patterns once at parse time: trims, lowercases, and strips blanks. /// - /// Array of filter patterns to normalize. - /// Normalized array of filter patterns. public static string[] Normalize(string[]? filters) { if (filters == null || filters.Length == 0) @@ -36,9 +34,6 @@ public static string[] Normalize(string[]? filters) /// Determines if the given path matches any of the provided glob patterns. /// Filters should be pre-normalized via . /// - /// Path to check. - /// Glob patterns to match against (pre-normalized). - /// True if the path matches any pattern; otherwise, false. public static bool Matches(string path, string[]? filters) { if (filters == null || filters.Length == 0) @@ -57,7 +52,6 @@ public static bool Matches(string path, string[]? filters) private static bool MatchesGlob(string input, string pattern) { - // Normalize path separators input = input.Replace('\\', '/'); pattern = pattern.Replace('\\', '/'); @@ -83,11 +77,10 @@ private static bool MatchesGlob(string input, string pattern) pattern, static p => { - // Convert glob pattern to regex // Escape all regex special chars except * and ? string regexPattern = Regex.Escape(p); - // Handle ** (globstar) before * — order matters + // Globstar must be handled before a lone *, or ** matches as two singles // **/ matches zero or more directory segments regexPattern = regexPattern.Replace("\\*\\*/", "(.*/)?", StringComparison.Ordinal); // standalone ** matches any characters including / diff --git a/CodeWalker.Cli/Helpers/HelpLayout.cs b/CodeWalker.Cli/Helpers/HelpLayout.cs index c56c45d17..a2236d053 100644 --- a/CodeWalker.Cli/Helpers/HelpLayout.cs +++ b/CodeWalker.Cli/Helpers/HelpLayout.cs @@ -65,8 +65,7 @@ internal sealed class HelpLayout : SynchronousCommandLineAction /// public static void Install(Command root) { - HelpOption? help = root.Options.OfType().FirstOrDefault(); - if (help != null) + foreach (HelpOption help in root.Options.OfType()) help.Action = new HelpLayout(); } @@ -79,7 +78,8 @@ public override int Invoke(ParseResult parseResult) // MaxWidth is unbounded when stdout is not a terminal; keep prose readable anyway. int width = Math.Min(100, Math.Max(40, this.inner.MaxWidth)); - if (command.Parents.Any() == false) + // Only worth stating once, on the root command. + if (!command.Parents.Any()) WriteStreams(output); WriteJsonFields(output, command.Name, width); diff --git a/CodeWalker.Cli/Helpers/SizeFormat.cs b/CodeWalker.Cli/Helpers/SizeFormat.cs index 26899ff99..c1e2df33c 100644 --- a/CodeWalker.Cli/Helpers/SizeFormat.cs +++ b/CodeWalker.Cli/Helpers/SizeFormat.cs @@ -41,9 +41,6 @@ private static string[] GetSuffixes(this SizeFormat format) => /// /// Formats the given byte size into a human-readable string based on the size format. /// - /// The size format to use. - /// The size in bytes to format. - /// A human-readable string representation of the byte size. public static string ToFormattedString(this SizeFormat format, long bytes) { double divisor = format.GetDivisor(); diff --git a/CodeWalker.Cli/Tests/Handlers/DiffHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/DiffHandlerTests.cs index a673c0341..f456d48dd 100644 --- a/CodeWalker.Cli/Tests/Handlers/DiffHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/DiffHandlerTests.cs @@ -194,7 +194,7 @@ public void CompareSides_UnhashedEntry_IsModified_NotUnchanged() [], Options() ); - Assert.Single(result.Modified); + _ = Assert.Single(result.Modified); Assert.Empty(result.Unchanged); } @@ -250,7 +250,7 @@ public void CompareSides_ErrorMessages_MarkResultUnsuccessful() Options() ); Assert.False(result.Success); - Assert.Single(result.ErrorMessages); + _ = Assert.Single(result.ErrorMessages); } } diff --git a/CodeWalker.Cli/Tests/Handlers/ExtractHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/ExtractHandlerTests.cs index 0069a864d..22e5789fc 100644 --- a/CodeWalker.Cli/Tests/Handlers/ExtractHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/ExtractHandlerTests.cs @@ -29,7 +29,7 @@ private static ExtractOptions MakeOptions(string rpfPath, bool json, bool dryRun Progress = false, }; - // ── Validation failures ──────────────────────────────────────────── + // Validation failures [Fact] public void Execute_MissingRpf_ReturnsOne() diff --git a/CodeWalker.Cli/Tests/Handlers/Gen9HandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/Gen9HandlerTests.cs index f518ee41d..316eacffe 100644 --- a/CodeWalker.Cli/Tests/Handlers/Gen9HandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/Gen9HandlerTests.cs @@ -39,7 +39,7 @@ private static string CreateTempDir() return dir; } - // ── Input folder missing ─────────────────────────────────────────── + // Input folder missing [Fact] public void Execute_InputFolderMissing_ReturnsOne() @@ -90,7 +90,7 @@ public void Execute_InputFolderMissing_Json_ReturnsErrorJson() } } - // ── Input equals output ──────────────────────────────────────────── + // Input equals output [Fact] public void Execute_InputEqualsOutput_ReturnsOne() @@ -144,7 +144,7 @@ public void Execute_InputEqualsOutput_Json_ReturnsErrorJson() finally { Directory.Delete(dir, true); } } - // ── Missing exe ──────────────────────────────────────────────────── + // Missing exe [Fact] public void Execute_MissingExe_ReturnsOne() @@ -180,7 +180,7 @@ public void Execute_MissingExe_ReturnsOne() } } - // ── JSON error structure ─────────────────────────────────────────── + // JSON error structure [Fact] public void Execute_Json_ErrorContainsExpectedFields() diff --git a/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs index cd56f28cf..e82350774 100644 --- a/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs @@ -11,7 +11,7 @@ namespace CodeWalker.Cli.Tests.Handlers; -// ── ParseEncoding ──────────────────────────────────────────────────── +// ParseEncoding public sealed class ParseEncodingTests { @@ -53,7 +53,7 @@ public void InvalidEncoding_ThrowsArgumentException(string input) } } -// ── ErrorResult ────────────────────────────────────────────────────── +// ErrorResult public sealed class ErrorResultTests { @@ -74,7 +74,7 @@ public void ErrorResult_PreservesErrorMessages() } } -// ── PrintHashes ────────────────────────────────────────────────────── +// PrintHashes [Collection("ConsoleOutput")] public sealed class PrintHashesTests @@ -160,7 +160,7 @@ public void Cancelled_ThrowsOperationCanceledException() } } -// ── PrintJsonHashes ───────────────────────────────────────────────── +// PrintJsonHashes [Collection("ConsoleOutput")] public sealed class PrintJsonHashesTests @@ -235,7 +235,7 @@ public void MultipleInputs_ReturnsAll() } } -// ── CollectHashes ──────────────────────────────────────────────────── +// CollectHashes public sealed class CollectHashesTests { @@ -334,7 +334,7 @@ public void Cancelled_ThrowsOperationCanceledException() } } -// ── Execute (integration) ──────────────────────────────────────────── +// Execute (integration) [Collection("ConsoleOutput")] public sealed class HashExecuteTests diff --git a/CodeWalker.Cli/Tests/Handlers/InspectHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/InspectHandlerTests.cs index 475e20769..957e5d93e 100644 --- a/CodeWalker.Cli/Tests/Handlers/InspectHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/InspectHandlerTests.cs @@ -12,7 +12,7 @@ namespace CodeWalker.Cli.Tests.Handlers; public sealed class InspectHandlerTests { - // ── FormatVector3 ───────────────────────────────────────────────── + // FormatVector3 [Fact] public void FormatVector3_Zero_ReturnsFormattedZeros() => @@ -44,7 +44,7 @@ public void FormatVector3_VerySmallValues() => Assert.Equal("0.01, 0.00, -0.01", InspectHandler.FormatVector3(new Vector3(0.01f, 0.001f, -0.01f))); - // ── Additional FormatVector3 edge cases ─────────────────────────── + // Additional FormatVector3 edge cases [Fact] public void FormatVector3_OneComponent() => diff --git a/CodeWalker.Cli/Tests/Handlers/ListHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/ListHandlerTests.cs index 4f4a6e620..a0b6b11f0 100644 --- a/CodeWalker.Cli/Tests/Handlers/ListHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/ListHandlerTests.cs @@ -56,7 +56,7 @@ private static RpfResourceFileEntry MakeResource(string name, string path, uint private static RpfFile MakeRpf(uint grandTotalRpfCount = 1) => new("test.rpf", "test.rpf", 0) { GrandTotalRpfCount = grandTotalRpfCount }; - // ── Validation failures ──────────────────────────────────────────── + // Validation failures [Fact] public void Execute_MissingRpf_ReturnsOne() @@ -164,7 +164,7 @@ public void Execute_MissingExe_WithExistingRpf_ReturnsOne() finally { Directory.Delete(dir, true); } } - // ── CollectList ──────────────────────────────────────────────────── + // CollectList [Fact] public void CollectList_EmptyEntries_ReturnsZeroTotals() @@ -328,7 +328,7 @@ public void CollectList_Cancelled_ThrowsOperationCanceledException() ); } - // ── ErrorResult ──────────────────────────────────────────────────── + // ErrorResult [Fact] public void ErrorResult_HasExpectedDefaults() @@ -357,7 +357,7 @@ public void ErrorResult_PreservesErrorMessages() Assert.Equal("err2", result.ErrorMessages[1]); } - // ── PrintList (text output) ──────────────────────────────────────── + // PrintList (text output) [Fact] public void PrintList_NonVerbose_PrintsPathsOnly() @@ -554,7 +554,7 @@ public void PrintList_Cancelled_ThrowsOperationCanceledException() } } - // ── PrintJsonList ────────────────────────────────────────────────── + // PrintJsonList [Fact] public void PrintJsonList_SerializesToStdout() diff --git a/CodeWalker.Cli/Tests/Handlers/PackHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/PackHandlerTests.cs index 5d4cb7074..8cd55c76e 100644 --- a/CodeWalker.Cli/Tests/Handlers/PackHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/PackHandlerTests.cs @@ -38,7 +38,7 @@ private static string CreateTempDir() return dir; } - // ── Input dir missing ────────────────────────────────────────────── + // Input dir missing [Fact] public void Execute_InputDirMissing_ReturnsOne() @@ -88,7 +88,7 @@ public void Execute_InputDirMissing_Json_ReturnsErrorJson() } } - // ── Output file exists without --force ───────────────────────────── + // Output file exists without --force [Fact] public void Execute_OutputExists_NoForce_ReturnsOne() @@ -153,7 +153,7 @@ public void Execute_OutputExists_NoForce_Json_ReturnsErrorJson() finally { Directory.Delete(inputDir, true); } } - // ── Missing exe ──────────────────────────────────────────────────── + // Missing exe [Fact] public void Execute_MissingExe_ReturnsOne() @@ -189,7 +189,7 @@ public void Execute_MissingExe_ReturnsOne() } } - // ── JSON error structure ─────────────────────────────────────────── + // JSON error structure [Fact] public void Execute_Json_ErrorContainsExpectedFields() diff --git a/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs index 1c32a72a1..cb20e96f4 100644 --- a/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs @@ -11,7 +11,7 @@ namespace CodeWalker.Cli.Tests.Handlers; -// ── ErrorResult ───────────────────────────────────────────────────── +// ErrorResult public sealed class SearchErrorResultTests { @@ -73,7 +73,7 @@ public void ErrorResult_PreservesErrorMessages() } } -// ── CollectSearch ─────────────────────────────────────────────────── +// CollectSearch public sealed class SearchCollectSearchTests { @@ -367,7 +367,7 @@ public void CollectSearch_SetsRpfFileAndPattern() } } -// ── CollectAllEntries ─────────────────────────────────────────────── +// CollectAllEntries public sealed class SearchCollectAllEntriesTests { @@ -483,7 +483,7 @@ public void CollectAllEntries_NullChildren_DoesNotThrow() } } -// ── Cancellation ──────────────────────────────────────────────────── +// Cancellation public sealed class SearchCancellationTests { @@ -533,7 +533,7 @@ public void CollectSearch_Cancelled_ThrowsOperationCanceledException() } } -// ── PrintSearch / PrintJsonSearch ──────────────────────────────────── +// PrintSearch / PrintJsonSearch [Collection("ConsoleOutput")] public sealed class SearchPrintTests @@ -587,7 +587,7 @@ private static Json.SearchMatch MakeMatch( Extension = extension, }; - // ── PrintSearch (text) ────────────────────────────────────────── + // PrintSearch (text) [Fact] public void PrintSearch_NonVerbose_PrintsPathsOnly() @@ -752,7 +752,7 @@ public void PrintSearch_Verbose_SIFormat_PrintsSIUnits() } } - // ── PrintJsonSearch ───────────────────────────────────────────── + // PrintJsonSearch [Fact] public void PrintJsonSearch_OutputsValidJson() @@ -803,7 +803,7 @@ public void PrintJsonSearch_EmptyMatches_OutputsEmptyArray() } } -// ── Execute (validation failures) ─────────────────────────────────── +// Execute (validation failures) [Collection("ConsoleOutput")] public sealed class SearchHandlerExecuteTests @@ -943,7 +943,7 @@ public void Execute_WithDirPath_Json_DelegatesToExecuteDirectory() } } -// ── CollectSearch Archive field ───────────────────────────────────── +// CollectSearch Archive field public sealed class SearchCollectSearchArchiveTests { @@ -1019,7 +1019,7 @@ public void CollectSearch_ExplicitArchive_SetsArchiveField() } } -// ── ErrorResult RpfFiles ──────────────────────────────────────────── +// ErrorResult RpfFiles public sealed class SearchErrorResultRpfFilesTests { @@ -1045,7 +1045,7 @@ public void ErrorResult_SetsEmptyRpfFiles() } } -// ── PrintSearch multi-archive ─────────────────────────────────────── +// PrintSearch multi-archive [Collection("ConsoleOutput")] public sealed class SearchPrintMultiArchiveTests @@ -1249,7 +1249,7 @@ public void PrintSearch_MultiRpf_Verbose_ShowsSizeAndArchiveHeaders() } } -// ── RelativePath ──────────────────────────────────────────────────── +// RelativePath public sealed class SearchRelativePathTests { @@ -1303,7 +1303,7 @@ public void RelativePath_MixedForwardAndBackslash() } } -// ── ExecuteDirectory ──────────────────────────────────────────────── +// ExecuteDirectory [Collection("ConsoleOutput")] public sealed class SearchExecuteDirectoryTests diff --git a/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs index 362f2566c..30b60fce8 100644 --- a/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs @@ -12,7 +12,7 @@ namespace CodeWalker.Cli.Tests.Handlers; -// ── ErrorResult ────────────────────────────────────────────────────── +// ErrorResult public sealed class StatErrorResultTests { @@ -81,7 +81,7 @@ public void ErrorResult_ExtensionsAreEmpty() } } -// ── CollectStats ───────────────────────────────────────────────────── +// CollectStats public sealed class CollectStatsTests { @@ -167,7 +167,7 @@ public void SingleBinary_CountsCorrectly() [Fact] public void SingleResource_CountsCorrectly() { - // 0x08000000 → SystemFlags.Size = 512, 0x04000000 → GraphicsFlags.Size = 1024 + // 0x08000000 -> SystemFlags.Size = 512, 0x04000000 -> GraphicsFlags.Size = 1024 RpfResourceFileEntry entry = MakeResource( "model.ydr", fileSize: 300, @@ -256,7 +256,7 @@ public void CompressionRatio_CalculatedCorrectly() [Fact] public void CompressionRatio_ZeroWhenNoUncompressed() { - // Entry with FileSize=0 and FileUncompressedSize=0 → GetFileSize() returns 0 + // Entry with FileSize=0 and FileUncompressedSize=0 -> GetFileSize() returns 0 RpfBinaryFileEntry entry = MakeBinary( "empty.dat", fileSize: 0, @@ -435,7 +435,7 @@ public void CaseInsensitiveExtensionGrouping() [Fact] public void CompressionRatio_RoundedToFourDecimals() { - // 1 / 3 = 0.33333... → should round to 0.3333 + // 1 / 3 = 0.33333... -> should round to 0.3333 RpfBinaryFileEntry entry = MakeBinary( "data.dat", fileSize: 1, @@ -459,7 +459,7 @@ public void CompressionRatio_RoundedToFourDecimals() [Fact] public void AvgSize_TruncatedByIntegerDivision() { - // 3 files totalling 10 bytes → avg = 10 / 3 = 3 (integer truncation, not 3.33) + // 3 files totalling 10 bytes -> avg = 10 / 3 = 3 (integer truncation, not 3.33) RpfBinaryFileEntry a = MakeBinary( "a.dat", fileSize: 1, @@ -519,7 +519,7 @@ public void Cancelled_ThrowsOperationCanceledException() } } -// ── PrintJsonStats ─────────────────────────────────────────────────── +// PrintJsonStats [Collection("ConsoleOutput")] public sealed class PrintJsonStatsTests @@ -681,7 +681,7 @@ public void PrintJsonStats_RoundTripsCorrectly() } } -// ── PrintStats (text) ──────────────────────────────────────────────── +// PrintStats (text) [Collection("ConsoleOutput")] public sealed class PrintStatsTests @@ -831,7 +831,7 @@ public void PrintStats_SIFormat_UsesDecimalUnits() } } -// ── Execute (integration) ──────────────────────────────────────────── +// Execute (integration) [Collection("ConsoleOutput")] public sealed class StatExecuteTests @@ -849,7 +849,7 @@ private static StatOptions MakeOptions(string rpfPath, bool json) => SizeFormat = SizeFormat.IEC }; - // ── Validation failures ──────────────────────────────────────────── + // Validation failures [Fact] public void Execute_MissingRpf_ReturnsOne() diff --git a/CodeWalker.Cli/Tests/Handlers/TreeHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/TreeHandlerTests.cs index 874df6bb4..6aadeddff 100644 --- a/CodeWalker.Cli/Tests/Handlers/TreeHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/TreeHandlerTests.cs @@ -13,7 +13,7 @@ namespace CodeWalker.Cli.Tests.Handlers; -// ── ErrorResult ────────────────────────────────────────────────────── +// ErrorResult public sealed class TreeErrorResultTests { @@ -69,7 +69,7 @@ public void ErrorResult_RootIsNull() } } -// ── PrintTree (text) ───────────────────────────────────────────────── +// PrintTree (text) [Collection("ConsoleOutput")] public sealed class PrintTreeTests @@ -230,7 +230,7 @@ public void PrintTree_LastDirectory_UsesSpacePrefix() ]) ]); (string stdout, _) = Capture(root, 1, 1); - // lastdir is last child → └──, its children use " " (4 spaces) prefix + // lastdir is last child -> └──, its children use " " (4 spaces) prefix Assert.Contains("\u2514\u2500\u2500 lastdir/", stdout); Assert.Contains(" \u2514\u2500\u2500 child.dat", stdout); } @@ -337,7 +337,7 @@ public void PrintTree_NullChildren_NoOutput() } } -// ── PrintJsonTree ──────────────────────────────────────────────────── +// PrintJsonTree [Collection("ConsoleOutput")] public sealed class PrintJsonTreeTests @@ -519,7 +519,7 @@ public void PrintJsonTree_PreservesRpfPath() } } -// ── PrintTreeChildren cancellation ─────────────────────────────────── +// PrintTreeChildren cancellation [Collection("ConsoleOutput")] public sealed class PrintTreeChildrenCancellationTests @@ -601,7 +601,7 @@ public void PrintTree_Cancelled_ThrowsOperationCanceledException() } } -// ── Execute (integration) ──────────────────────────────────────────── +// Execute (integration) [Collection("ConsoleOutput")] public sealed class TreeExecuteTests @@ -620,7 +620,7 @@ private static TreeOptions MakeOptions(string rpfPath, bool json, int depth = -1 Depth = depth, }; - // ── Validation failures ──────────────────────────────────────────── + // Validation failures [Fact] public void Execute_MissingRpf_ReturnsOne() @@ -732,7 +732,7 @@ public void Execute_MissingExe_WithExistingRpf_ReturnsOne() } } -// ── CollectChildren ───────────────────────────────────────────────── +// CollectChildren public sealed class CollectChildrenTests { @@ -961,7 +961,7 @@ public void DirsAndFiles_OrderedCorrectly() } } -// ── BuildTreeNode ─────────────────────────────────────────────────── +// BuildTreeNode public sealed class BuildTreeNodeTests { diff --git a/CodeWalker.Cli/Tests/Handlers/ValidateHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/ValidateHandlerTests.cs index 65f0d34be..72b8322c8 100644 --- a/CodeWalker.Cli/Tests/Handlers/ValidateHandlerTests.cs +++ b/CodeWalker.Cli/Tests/Handlers/ValidateHandlerTests.cs @@ -26,7 +26,7 @@ private static ValidateOptions MakeOptions(string rpfPath, bool json) => Progress = false, }; - // ── Validation failures ──────────────────────────────────────────── + // Validation failures [Fact] public void Execute_MissingRpf_ReturnsOne() diff --git a/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs b/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs index 24d947e4f..2164c5b03 100644 --- a/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs +++ b/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs @@ -9,7 +9,7 @@ namespace CodeWalker.Cli.Tests.Helpers; public sealed class ProgressBarTests { - // ── Helper ────────────────────────────────────────────────────────── + // Helper /// Increments the bar times, optionally passing a file on the last call. private static void IncrementTo(ProgressBar bar, int count, string? lastFile = null) @@ -20,7 +20,7 @@ private static void IncrementTo(ProgressBar bar, int count, string? lastFile = n bar.Increment(lastFile); } - // ── Disabled-state tests ────────────────────────────────────────── + // Disabled-state tests [Fact] public void Constructor_disabled_when_enabled_is_false() @@ -47,7 +47,7 @@ public void Constructor_disabled_when_total_is_negative() Assert.False(bar.Enabled); } - // ── Enabled-state tests ─────────────────────────────────────────── + // Enabled-state tests [Fact] public void Constructor_enabled_with_custom_writer() @@ -68,7 +68,7 @@ public void Constructor_renders_initial_zero_percent() Assert.Contains(">", output); // cursor indicator at start } - // ── Render format tests ─────────────────────────────────────────── + // Render format tests [Fact] public void Render_at_50_percent_has_half_filled_bar() @@ -99,7 +99,7 @@ public void Render_at_100_percent_has_full_bar_no_cursor() Assert.Contains("(10/10)", output); } - // ── File name tests ─────────────────────────────────────────────── + // File name tests [Fact] public void Render_shows_current_file() @@ -116,7 +116,7 @@ public void Render_shows_current_file() public void Render_truncates_long_file_with_ellipsis() { StringWriter sw = new(); - // windowWidth=80 → maxLen = Max(10, 80-40-30) = 10 + // windowWidth=80 -> maxLen = Max(10, 80-40-30) = 10 using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 80); _ = sw.GetStringBuilder().Clear(); IncrementTo(bar, 10, "very/long/path/to/some/deeply/nested/file.ytd"); @@ -153,7 +153,7 @@ public void Render_shows_short_file_without_truncation() Assert.DoesNotContain("...", output); } - // ── State tracking tests ────────────────────────────────────────── + // State tracking tests [Fact] public void Increment_advances_by_one() @@ -166,7 +166,7 @@ public void Increment_advances_by_one() Assert.Equal(3, bar.Current); } - // ── Throttle tests ──────────────────────────────────────────────── + // Throttle tests [Fact] public void Throttle_skips_rapid_increments() @@ -174,7 +174,7 @@ public void Throttle_skips_rapid_increments() StringWriter sw = new(); using ProgressBar bar = new(100, enabled: true, sw, windowWidth: 80); _ = sw.GetStringBuilder().Clear(); - // Rapid increments within the 50ms throttle window — none should render + // Rapid increments within the 50ms throttle window - none should render for (int i = 1; i <= 50; i++) bar.Increment(); string output = sw.ToString(); @@ -203,7 +203,7 @@ public void Throttle_reset_allows_render() Assert.Contains("(1/100)", sw.ToString()); } - // ── Dispose tests ───────────────────────────────────────────────── + // Dispose tests [Fact] public void Dispose_writes_newline_when_enabled() @@ -229,7 +229,7 @@ public void Dispose_can_be_called_multiple_times() bar.Dispose(); } - // ── Thread safety tests ─────────────────────────────────────────── + // Thread safety tests [Fact] public void Concurrent_increments_are_thread_safe() @@ -243,7 +243,7 @@ public void Concurrent_increments_are_thread_safe() Assert.Equal(total, bar.Current); } - // ── Disabled-state mutation tests ────────────────────────────────── + // Disabled-state mutation tests [Fact] public void Increment_on_disabled_bar_writes_nothing() @@ -254,7 +254,7 @@ public void Increment_on_disabled_bar_writes_nothing() Assert.Equal("", sw.ToString()); } - // ── Clamping tests ────────────────────────────────────────────── + // Clamping tests [Fact] public void Increment_clamps_current_at_total() @@ -266,7 +266,7 @@ public void Increment_clamps_current_at_total() Assert.Equal(3, bar.Current); } - // ── Render exception handling tests ────────────────────────────── + // Render exception handling tests [Fact] public void Render_swallows_IOException_from_writer() @@ -283,7 +283,7 @@ private sealed class ThrowingWriter : StringWriter public override void Write(string? value) => throw new IOException("simulated"); } - // ── Dispose idempotency tests ─────────────────────────────────── + // Dispose idempotency tests [Fact] public void Dispose_writes_exactly_one_newline() @@ -298,7 +298,7 @@ public void Dispose_writes_exactly_one_newline() Assert.Equal(sw.NewLine, sw.ToString()); } - // ── Full lifecycle test ─────────────────────────────────────────── + // Full lifecycle test [Fact] public void Full_lifecycle_renders_progress_to_completion() @@ -315,7 +315,7 @@ public void Full_lifecycle_renders_progress_to_completion() Assert.Equal(5, bar.Current); } - // ── Edge case tests ────────────────────────────────────────────── + // Edge case tests [Fact] public void Increment_with_empty_file_name_does_not_display_file() diff --git a/CodeWalker.Cli/Tests/PolyfillsTests.cs b/CodeWalker.Cli/Tests/PolyfillsTests.cs index ecb7c621f..eb33d1619 100644 --- a/CodeWalker.Cli/Tests/PolyfillsTests.cs +++ b/CodeWalker.Cli/Tests/PolyfillsTests.cs @@ -70,7 +70,7 @@ private static string[] BuildCorpus() "/", "\\", "\0", "🎮", "xx", ]; - // ─── Contains(string, StringComparison) ───────────────────────── + // Contains(string, StringComparison) // Polyfill wraps IndexOf; built-in is the native implementation. [Fact] @@ -85,7 +85,7 @@ public void Contains_String_Comparison_MatchesBuiltIn() $"Contains(\"{Esc(s)}\", \"{Esc(sub)}\", {cmp})"); } - // ─── Contains(char) ───────────────────────────────────────────── + // Contains(char) // Built-in string.Contains(char) exists on .NET 5+. [Fact] @@ -99,7 +99,7 @@ public void Contains_Char_MatchesBuiltIn() $"Contains(\"{Esc(s)}\", '{c}')"); } - // ─── Contains(char, StringComparison) ─────────────────────────── + // Contains(char, StringComparison) // No built-in char overload; verify against string-based Contains. [Fact] @@ -114,7 +114,7 @@ public void Contains_Char_Comparison_MatchesStringOverload() $"Contains(\"{Esc(s)}\", '{c}', {cmp})"); } - // ─── StartsWith(char) ─────────────────────────────────────────── + // StartsWith(char) // Verify against string-based StartsWith with Ordinal comparison. [Fact] @@ -128,7 +128,7 @@ public void StartsWith_Char_MatchesStringOverload() $"StartsWith(\"{Esc(s)}\", '{c}')"); } - // ─── EndsWith(char) ───────────────────────────────────────────── + // EndsWith(char) // Verify against string-based EndsWith with Ordinal comparison. [Fact] @@ -142,7 +142,7 @@ public void EndsWith_Char_MatchesStringOverload() $"EndsWith(\"{Esc(s)}\", '{c}')"); } - // ─── Replace(string, string?, StringComparison) ───────────────── + // Replace(string, string?, StringComparison) // Ordinal path delegates to built-in; non-Ordinal uses ReplaceCore. [Fact] @@ -161,7 +161,7 @@ public void Replace_MatchesBuiltIn() $"Replace(\"{Esc(s)}\", \"{Esc(old)}\", \"{Esc(@new)}\", {cmp})"); } - // ─── Helpers ──────────────────────────────────────────────────── + // Helpers private static void AssertBool(bool expected, bool actual, string label) => Assert.True(expected == actual, $"{label}: expected={expected} actual={actual}"); From 255bd196c97db654ca58d78557fab0defbd70286 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:44:06 +0200 Subject: [PATCH 35/45] chore(cli): fix launch config paths and patch Bcl.Memory Directory.Build.props redirects build output to bin/$(MSBuildProjectName) so the two projects sharing this directory do not collide, but the VS Code launch configurations still pointed at bin/Debug, so none of them could start the CLI. IndexRange 1.1.0 brings Microsoft.Bcl.Memory 9.0.0 in on net48, which carries GHSA-73j8-2gch-69rq; pinned to the patched 10.0.4. The build is warning-free again. --- .vscode/launch.json | 6 +++--- CodeWalker.Cli/Directory.Build.props | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 60b31bceb..32c2c4959 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ "type": "clr", "request": "launch", "preLaunchTask": "build-cli", - "program": "${workspaceFolder}/CodeWalker.Cli/bin/Debug/net48/CodeWalker.Cli.exe", + "program": "${workspaceFolder}/CodeWalker.Cli/bin/CodeWalker.Cli/Debug/net48/CodeWalker.Cli.exe", "args": [], "cwd": "${workspaceFolder}", "console": "integratedTerminal" @@ -16,7 +16,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build-cli", - "program": "${workspaceFolder}/CodeWalker.Cli/bin/Debug/net8.0/CodeWalker.Cli.dll", + "program": "${workspaceFolder}/CodeWalker.Cli/bin/CodeWalker.Cli/Debug/net8.0/CodeWalker.Cli.dll", "args": [], "cwd": "${workspaceFolder}", "console": "integratedTerminal" @@ -26,7 +26,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build-cli", - "program": "${workspaceFolder}/CodeWalker.Cli/bin/Debug/net10.0/CodeWalker.Cli.dll", + "program": "${workspaceFolder}/CodeWalker.Cli/bin/CodeWalker.Cli/Debug/net10.0/CodeWalker.Cli.dll", "args": [], "cwd": "${workspaceFolder}", "console": "integratedTerminal" diff --git a/CodeWalker.Cli/Directory.Build.props b/CodeWalker.Cli/Directory.Build.props index 9e1edb187..f3a389392 100644 --- a/CodeWalker.Cli/Directory.Build.props +++ b/CodeWalker.Cli/Directory.Build.props @@ -31,6 +31,12 @@ Version="1.1.0" Condition="'$(TargetFramework)' == 'net48'" /> + + From 9a5551219c5a81e2b050fb5c2201fb7cb67ae0d4 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:48:29 +0200 Subject: [PATCH 36/45] fix(cli): format numbers invariantly Sizes, the compression ratio and vector components were formatted with the ambient culture, so a machine with a comma decimal separator emitted '18,28 MiB' where another emitted '18.28 MiB'. Those strings are not only printed: they are the *Formatted fields in --json, which is documented for scripting, so the same archive described itself differently depending on where the tool ran. --- CodeWalker.Cli/Handlers/InspectHandler.cs | 3 ++- CodeWalker.Cli/Handlers/StatHandler.cs | 8 +++++++- CodeWalker.Cli/Helpers/SizeFormat.cs | 5 ++++- .../Tests/Helpers/SizeFormatTests.cs | 20 +++++++++++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/CodeWalker.Cli/Handlers/InspectHandler.cs b/CodeWalker.Cli/Handlers/InspectHandler.cs index 34c0459e6..dd923de26 100644 --- a/CodeWalker.Cli/Handlers/InspectHandler.cs +++ b/CodeWalker.Cli/Handlers/InspectHandler.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.CommandLine; +using System.Globalization; using System.IO; using System.Linq; using System.Text.Json; @@ -495,7 +496,7 @@ private static void AddLod(List lods, string level, DrawableModel[ } internal static string FormatVector3(Vector3 v) => - $"{v.X:F2}, {v.Y:F2}, {v.Z:F2}"; + string.Format(CultureInfo.InvariantCulture, "{0:F2}, {1:F2}, {2:F2}", v.X, v.Y, v.Z); private static void PrintTextResult(Json.InspectResult result, InspectOptions options) { diff --git a/CodeWalker.Cli/Handlers/StatHandler.cs b/CodeWalker.Cli/Handlers/StatHandler.cs index 0cc2c73e6..b844e461c 100644 --- a/CodeWalker.Cli/Handlers/StatHandler.cs +++ b/CodeWalker.Cli/Handlers/StatHandler.cs @@ -334,7 +334,13 @@ .. result.Extensions string compressedStr = options.SizeFormat.ToFormattedString(result.CompressedSize); string uncompressedStr = options.SizeFormat.ToFormattedString(result.UncompressedSize); Console.Error.WriteLine( - $"Compression: {compressedStr} / {uncompressedStr} ({result.CompressionRatio:P1} of original)" + string.Format( + CultureInfo.InvariantCulture, + "Compression: {0} / {1} ({2:P1} of original)", + compressedStr, + uncompressedStr, + result.CompressionRatio + ) ); } } diff --git a/CodeWalker.Cli/Helpers/SizeFormat.cs b/CodeWalker.Cli/Helpers/SizeFormat.cs index c1e2df33c..f5a4d012f 100644 --- a/CodeWalker.Cli/Helpers/SizeFormat.cs +++ b/CodeWalker.Cli/Helpers/SizeFormat.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; namespace CodeWalker.Cli.Helpers; @@ -40,6 +41,8 @@ private static string[] GetSuffixes(this SizeFormat format) => /// /// Formats the given byte size into a human-readable string based on the size format. + /// Invariant, so the same archive reports the same figures on every machine and the + /// formatted values in --json output stay stable. /// public static string ToFormattedString(this SizeFormat format, long bytes) { @@ -53,6 +56,6 @@ public static string ToFormattedString(this SizeFormat format, long bytes) i++; } if (bytes < 0) size = -size; - return $"{size:0.##} {suffixes[i]}"; + return string.Format(CultureInfo.InvariantCulture, "{0:0.##} {1}", size, suffixes[i]); } } diff --git a/CodeWalker.Cli/Tests/Helpers/SizeFormatTests.cs b/CodeWalker.Cli/Tests/Helpers/SizeFormatTests.cs index 9c6f457f2..3e36e328d 100644 --- a/CodeWalker.Cli/Tests/Helpers/SizeFormatTests.cs +++ b/CodeWalker.Cli/Tests/Helpers/SizeFormatTests.cs @@ -1,4 +1,6 @@ using System; +using System.Globalization; +using System.Threading; using CodeWalker.Cli.Helpers; @@ -154,4 +156,22 @@ public void InvalidFormat_Throws() invalid.ToFormattedString(1337)); } } + + [Theory] + [InlineData("de-DE")] + [InlineData("fr-FR")] + [InlineData("tr-TR")] + public void ToFormattedString_IsInvariantOfCurrentCulture(string culture) + { + // These strings also travel inside --json as the *Formatted fields, so a comma-decimal + // machine must not produce different output from a dot-decimal one. + CultureInfo previous = Thread.CurrentThread.CurrentCulture; + try + { + Thread.CurrentThread.CurrentCulture = new CultureInfo(culture); + Assert.Equal("1.5 KiB", SizeFormat.IEC.ToFormattedString(1536)); + Assert.Equal("1.5 KB", SizeFormat.SI.ToFormattedString(1500)); + } + finally { Thread.CurrentThread.CurrentCulture = previous; } + } } From b5d75f717b7e8d645f361be478d98bccf562dce1 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:23:04 +0200 Subject: [PATCH 37/45] fix(cli): mirror the archive structure when exporting RPF entry paths are separated with backslashes on every platform, but the export pipeline handed the raw path to Path.GetDirectoryName before translating them. On a platform whose separator is not a backslash the whole path reads as a bare file name, so the directory came back empty and every exported file landed in the output root. Entries sharing a name then overwrote each other silently: exporting the localization files out of x64b.rpf reported 6804 written and left 567 on disk, one per name rather than one per language. Extract already translated the separators before splitting the path; export now does the same, so both mirror the archive. --- CodeWalker.Cli/Helpers/ExportPipeline.cs | 6 ++-- CodeWalker.Cli/Tests/ExportPipelineTests.cs | 32 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/CodeWalker.Cli/Helpers/ExportPipeline.cs b/CodeWalker.Cli/Helpers/ExportPipeline.cs index 46cf5e026..efd65a5e8 100644 --- a/CodeWalker.Cli/Helpers/ExportPipeline.cs +++ b/CodeWalker.Cli/Helpers/ExportPipeline.cs @@ -62,9 +62,11 @@ internal static (Json.ExportFileEntry? entry, string? error) ProcessSingleFile( ExportFileProcessor processor ) { + // RPF entry paths are separated with backslashes. Path.GetDirectoryName only + // recognises the platform separator, so they must be translated first or the + // whole path reads as a bare file name and every export lands in the root. string relativePath = - Path.GetDirectoryName(fileEntry.Path) - ?.Replace('\\', Path.DirectorySeparatorChar) + Path.GetDirectoryName(fileEntry.Path.Replace('\\', Path.DirectorySeparatorChar)) ?? ""; string fileOutputDir = Path.Combine(outputDir, relativePath); diff --git a/CodeWalker.Cli/Tests/ExportPipelineTests.cs b/CodeWalker.Cli/Tests/ExportPipelineTests.cs index d5df77b4b..82d8508c7 100644 --- a/CodeWalker.Cli/Tests/ExportPipelineTests.cs +++ b/CodeWalker.Cli/Tests/ExportPipelineTests.cs @@ -122,6 +122,38 @@ public sealed class ProcessSingleFileTests private static RpfBinaryFileEntry MakeEntry(string path, string name) => new() { Path = path, Name = name }; + [Fact] + public void BackslashEntryPath_MirrorsArchiveStructureInOutputDir() + { + // RPF entry paths use backslashes on every platform. If they are not translated + // before the directory is split off, every file collapses into the output root and + // entries sharing a name overwrite each other. + string? seen = null; + _ = ExportPipeline.ProcessSingleFile( + MakeEntry(@"x64b.rpf\data\lang\spanish_rel.rpf\yoga.gxt2", "yoga.gxt2"), + data: [1], + outputDir: "/out", + dryRun: false, + noOverwrite: false, + processor: (entry, _, fileOutputDir, _) => + { + seen = fileOutputDir; + return (new Json.ExportFileEntry + { + Path = entry.Path, + Name = entry.Name, + OutputFiles = 1, + Status = "exported", + }, null); + } + ); + + Assert.Equal( + Path.Combine("/out", "x64b.rpf", "data", "lang", "spanish_rel.rpf"), + seen + ); + } + [Fact] public void DryRun_ReturnsEntryWithNoError() { From 99bbe3d8015cf33142a010b9c8ee0275260a86ff Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:27:07 +0200 Subject: [PATCH 38/45] chore(cli): force crlf checkouts to match .editorconfig The project sets end_of_line = crlf and EnforceCodeStyleInBuild makes dotnet format check the file as it sits on disk, but the repository root normalizes with '* text=auto', so a non-Windows checkout writes LF and every file reports ENDOFLINE. The two could never agree off Windows. A .gitattributes scoped to this project forces crlf on checkout everywhere. Blobs are still stored normalized, and the root file is left alone so nothing conflicts with upstream. --- CodeWalker.Cli/.gitattributes | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 CodeWalker.Cli/.gitattributes diff --git a/CodeWalker.Cli/.gitattributes b/CodeWalker.Cli/.gitattributes new file mode 100644 index 000000000..e84981f3f --- /dev/null +++ b/CodeWalker.Cli/.gitattributes @@ -0,0 +1,6 @@ +# This project's .editorconfig sets end_of_line = crlf, and EnforceCodeStyleInBuild +# means dotnet format checks the file on disk. The repository root normalizes to LF +# with `* text=auto`, so on a non-Windows checkout the two disagree and every file +# reports ENDOFLINE. Forcing crlf on checkout keeps them in step on every platform; +# blobs are still stored normalized. +* text=auto eol=crlf From 5e08bfee3b212023eb1483a5bac4f85643ded20f Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:28:11 +0200 Subject: [PATCH 39/45] ci: build, test and format-check the CLI on push and pull request Nothing ran the build or the 534 tests automatically; the repository had a .github directory with only FUNDING.yml in it. Windows covers all three target frameworks, including net48 natively. Linux is not redundant coverage: the archive-name path join in pack, and the export pipeline collapsing its output into one directory, both only reproduced off Windows, so a Linux leg is what would have caught them. Scoped by path to CodeWalker.Cli and CodeWalker.Core, since the rest of the solution is Windows-only WinForms and shader projects that do not build in a plain runner. The build passes -warnaserror. The project is warning-free today, so this is a regression gate; note it also promotes NuGet advisory warnings, which can appear on a branch that has not changed. --- .github/workflows/cli.yml | 73 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 .github/workflows/cli.yml diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml new file mode 100644 index 000000000..396acc868 --- /dev/null +++ b/.github/workflows/cli.yml @@ -0,0 +1,73 @@ +name: CLI + +on: + push: + branches: [main, master, cli] + paths: + - CodeWalker.Cli/** + - CodeWalker.Core/** + - .github/workflows/cli.yml + pull_request: + paths: + - CodeWalker.Cli/** + - CodeWalker.Core/** + - .github/workflows/cli.yml + workflow_dispatch: + +permissions: + contents: read + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + +jobs: + test: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + # Windows covers net48 natively. Linux is not redundant: two of the + # path bugs this project has hit only reproduce off Windows. + - os: windows-latest + frameworks: net48 net8.0 net10.0 + - os: ubuntu-latest + frameworks: net8.0 net10.0 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Build + run: dotnet build CodeWalker.Cli/CodeWalker.Cli.csproj -warnaserror + + - name: Test + shell: bash + run: | + for framework in ${{ matrix.frameworks }}; do + echo "::group::$framework" + dotnet test CodeWalker.Cli/CodeWalker.Cli.Tests.csproj -f "$framework" + echo "::endgroup::" + done + + format: + name: format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + + - name: Verify formatting + run: | + dotnet format CodeWalker.Cli/CodeWalker.Cli.csproj --verify-no-changes + dotnet format CodeWalker.Cli/CodeWalker.Cli.Tests.csproj --verify-no-changes From 2e602978da4d2a824e4615e0f033104897ffee26 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:47:20 +0200 Subject: [PATCH 40/45] fix(core): stop shrinking the entry when a resource fails to decompress ExtractFileResource skips the 16 byte header, and when DecompressBytes returns null it hands back the still-compressed bytes and subtracts those 16 from entry.FileSize to match. The entry is shared, so that is permanent: each extraction of the same file reads 16 bytes less than the one before. pass 0: entry.FileSize=73437 extracted=73421 pass 1: entry.FileSize=73405 extracted=73405 pass 2: entry.FileSize=73389 extracted=73389 It is also a data race: the CLI extracts across sixteen threads, and nothing guards the field. The data is short either way, and the caller has to deal with that. ResourceDataReader built its two MemoryStreams straight from the page flags, so a buffer smaller than they describe surfaced as an ArgumentException about offset and length with nothing to say which file it was. It checks first, and names the file and both sizes. Found on one entry in x64a.rpf of a GTA V Enhanced install whose deflate stream decodes at no offset. The other 24002 resources checked across five archives decompress to exactly their flagged size, so this is one bad file rather than a format CodeWalker cannot read. Not covered by a test: reaching this path needs a real archive holding a deliberately corrupt resource, and there is no test project over CodeWalker.Core to build one in. --- CodeWalker.Core/GameFiles/Resources/ResourceData.cs | 10 ++++++++++ CodeWalker.Core/GameFiles/RpfFile.cs | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CodeWalker.Core/GameFiles/Resources/ResourceData.cs b/CodeWalker.Core/GameFiles/Resources/ResourceData.cs index c734cc603..f0ea2fa7e 100644 --- a/CodeWalker.Core/GameFiles/Resources/ResourceData.cs +++ b/CodeWalker.Core/GameFiles/Resources/ResourceData.cs @@ -105,6 +105,16 @@ public ResourceDataReader(RpfResourceFileEntry resentry, byte[] data, Endianess // } //} + if ((data == null) || (((long)systemSize + graphicsSize) > data.Length)) + { + //the entry's page flags describe more data than was extracted, which happens when + //the resource couldn't be decompressed. the MemoryStream below would throw anyway, + //but without saying which file it was or what was wrong with it. + throw new InvalidDataException(string.Format( + "Resource data for {0} is {1} bytes, but its page flags require {2} (system {3} + graphics {4}).", + resentry?.Name ?? "(unknown)", data?.Length ?? 0, (long)systemSize + graphicsSize, systemSize, graphicsSize)); + } + this.systemStream = new MemoryStream(data, 0, systemSize); this.graphicsStream = new MemoryStream(data, systemSize, graphicsSize); Position = 0x50000000; diff --git a/CodeWalker.Core/GameFiles/RpfFile.cs b/CodeWalker.Core/GameFiles/RpfFile.cs index 0fa2b6a7b..9251490cb 100644 --- a/CodeWalker.Core/GameFiles/RpfFile.cs +++ b/CodeWalker.Core/GameFiles/RpfFile.cs @@ -621,7 +621,9 @@ public byte[] ExtractFileResource(RpfResourceFileEntry entry, BinaryReader br) } else { - entry.FileSize -= offset; + //couldn't decompress it, so give back what's there. it is shorter than + //the entry's flags describe, and the entry is shared, so FileSize keeps + //describing what is on disk and the caller has to notice the shortfall. data = decr; } From bc1364ac9fe5884ff574b669e461cb0bb6c4b593 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:53:56 +0200 Subject: [PATCH 41/45] ci: print the test log when a run fails The xunit runner writes its failures to a file under TestResults and prints only the counts, so a failed run said "Failed: 1, Passed: 533" and named neither the test nor the assertion. The log is dumped on failure. The loop also ran under -e, so the first framework to fail skipped the rest. It runs all of them and fails afterwards, which matters here because net48 is first and the two modern targets never got a chance to report. --- .github/workflows/cli.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 396acc868..72d91ffe1 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -51,11 +51,21 @@ jobs: - name: Test shell: bash run: | + status=0 for framework in ${{ matrix.frameworks }}; do echo "::group::$framework" - dotnet test CodeWalker.Cli/CodeWalker.Cli.Tests.csproj -f "$framework" + dotnet test CodeWalker.Cli/CodeWalker.Cli.Tests.csproj -f "$framework" || status=1 echo "::endgroup::" done + exit $status + + # The runner reports failures to a file rather than to stdout, so without + # this a failed run names no test. + - name: Show test logs + if: failure() + shell: bash + run: | + find CodeWalker.Cli/bin -path '*TestResults*' -name '*.log' -print -exec cat {} + format: name: format From 91b6b2f67a6a0ccc6f2e01b699eb46b489c1d07f Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:58:06 +0200 Subject: [PATCH 42/45] test(cli): drop the export output directory test that assumed a slash OutputDirectory_ComputedFromBackslashPath asserted that the directory an export lands in contains no backslash. That only holds where the platform separator is not a backslash, so it failed on Windows once the translation it was named for actually worked: Assert.DoesNotContain() Failure: Sub-string found String: "/out\x64\levels\gta5\vehicles.rpf" Found: "\" It never tested what it claimed either. It was written while the separators were not translated at all, and back then the directory came back as the bare output root, which contains no backslash and starts with /out, so both of its assertions passed against the bug. BackslashEntryPath_MirrorsArchiveStructureInOutputDir covers the same path and compares against Path.Combine of the expected segments, which holds on either separator. --- CodeWalker.Cli/Tests/ExportPipelineTests.cs | 32 --------------------- 1 file changed, 32 deletions(-) diff --git a/CodeWalker.Cli/Tests/ExportPipelineTests.cs b/CodeWalker.Cli/Tests/ExportPipelineTests.cs index 82d8508c7..a800b88ca 100644 --- a/CodeWalker.Cli/Tests/ExportPipelineTests.cs +++ b/CodeWalker.Cli/Tests/ExportPipelineTests.cs @@ -313,38 +313,6 @@ public void ProcessorThrows_ExceptionPropagates() Assert.Equal("processor crashed", ex.Message); } - - [Fact] - public void OutputDirectory_ComputedFromBackslashPath() - { - string? capturedOutputDir = null; - - _ = ExportPipeline.ProcessSingleFile( - MakeEntry("x64\\levels\\gta5\\vehicles.rpf\\adder.ydr", "adder.ydr"), - data: [1], - outputDir: "/out", - dryRun: false, - noOverwrite: false, - processor: (_, _, dir, _) => - { - capturedOutputDir = dir; - return ( - new Json.ExportFileEntry - { - Path = "adder.ydr", - Name = "adder.ydr", - OutputFiles = 1, - Status = "exported", - }, - null - ); - } - ); - - Assert.NotNull(capturedOutputDir); - Assert.DoesNotContain("\\", capturedOutputDir); - Assert.StartsWith("/out", capturedOutputDir); - } } [Collection("ConsoleOutput")] From 504e9bf9a9bb5fded88f6a940941b3e59afb3271 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:26:15 +0200 Subject: [PATCH 43/45] ci: build the whole solution on windows The existing jobs build CodeWalker.Cli and nothing else, so a change to CodeWalker.Core that breaks the WinForms projects consuming it would pass. This branch already carries such a change, and none of it had been compiled anywhere. msbuild rather than dotnet build, because the solution includes a C++ project for the shaders. Debug rather than Release: CodeWalker.csproj sets PlatformTarget to x64 only under Release|AnyCPU, and the SDK then infers a win-x64 RuntimeIdentifier that restore has not produced, which fails as NETSDK1047. One invocation with -restore keeps the restore and the build on the same evaluation. No -warnaserror here. This is a compile gate over code the CLI work does not own; the warning gate stays on the project this branch is for. The path filters gain the projects the job builds, so a change confined to the GUI still runs it. --- .github/workflows/cli.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 72d91ffe1..b38f4cc20 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -6,11 +6,19 @@ on: paths: - CodeWalker.Cli/** - CodeWalker.Core/** + - CodeWalker.ModManager/** + - CodeWalker.WinForms/** + - CodeWalker/** + - CodeWalker.sln - .github/workflows/cli.yml pull_request: paths: - CodeWalker.Cli/** - CodeWalker.Core/** + - CodeWalker.ModManager/** + - CodeWalker.WinForms/** + - CodeWalker/** + - CodeWalker.sln - .github/workflows/cli.yml workflow_dispatch: @@ -67,6 +75,26 @@ jobs: run: | find CodeWalker.Cli/bin -path '*TestResults*' -name '*.log' -print -exec cat {} + + solution: + name: solution + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - uses: microsoft/setup-msbuild@v3 + + # The CLI jobs build one project. This is what notices when a change to + # CodeWalker.Core breaks the WinForms projects that also consume it. + # msbuild rather than dotnet build: the solution carries a C++ project. + - name: Build + run: msbuild CodeWalker.sln -restore -p:Configuration=Debug -m + format: name: format runs-on: ubuntu-latest From 8fdec02614e2554fc588f771a5e92a1c7cd607a5 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:06:48 +0200 Subject: [PATCH 44/45] ci: publish build artifacts Nothing was kept from a run, so trying a build meant compiling it. The CLI publishes three ways: self-contained single-file for win-x64 and linux-x64 from net10.0, which run with nothing installed, and a net48 build for Windows machines that already have the framework. No trimming, since the JSON output goes through reflection. The solution job uploads the Release output of all seven apps, one folder per project because each carries its own copy of the shared assemblies. It built Debug until now because Release failed with NETSDK1047: CodeWalker.csproj sets PlatformTarget to x64 under Release|AnyCPU and the SDK then infers a win-x64 RuntimeIdentifier that restore has not produced. Clearing RuntimeIdentifier keeps the AnyCPU assets, so the artifacts are Release builds and no project file changes. --- .github/workflows/cli.yml | 43 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index b38f4cc20..fa5fd2f0e 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -92,8 +92,49 @@ jobs: # The CLI jobs build one project. This is what notices when a change to # CodeWalker.Core breaks the WinForms projects that also consume it. # msbuild rather than dotnet build: the solution carries a C++ project. + # CodeWalker.csproj sets PlatformTarget to x64 under Release|AnyCPU and the + # SDK then infers a win-x64 RuntimeIdentifier that restore has not produced, + # which fails as NETSDK1047; clearing it keeps the AnyCPU assets. - name: Build - run: msbuild CodeWalker.sln -restore -p:Configuration=Debug -m + run: msbuild CodeWalker.sln -restore -p:Configuration=Release -p:RuntimeIdentifier= -m + + - uses: actions/upload-artifact@v7 + with: + name: codewalker-solution + path: '*/bin/Release/net48/' + + publish: + name: publish ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + name: win-x64 + args: -f net10.0 -r win-x64 --self-contained true -p:PublishSingleFile=true + - os: ubuntu-latest + name: linux-x64 + args: -f net10.0 -r linux-x64 --self-contained true -p:PublishSingleFile=true + - os: windows-latest + name: net48 + args: -f net48 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Publish + run: dotnet publish CodeWalker.Cli/CodeWalker.Cli.csproj -c Release ${{ matrix.args }} -o publish + + - uses: actions/upload-artifact@v7 + with: + name: codewalker-cli-${{ matrix.name }} + path: publish format: name: format From c3022feb1c3a673c2e1f1641c80a3a64c6e33d22 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:13:47 +0200 Subject: [PATCH 45/45] docs: update the requirements and document the command line program The requirements list was the app's, with nothing saying so, and it had drifted: .NET Framework 4.5 where the projects target 4.8, behind a link to the 4.7.1 download, and no mention of Enhanced although the tree has Gen9 support. Each list now names the program it is for, and the command line program's drops the graphics and memory figures that do not apply. CodeWalker.Cli was not mentioned anywhere. It gets a section next to the menu and explorer modes, since all three are ways of using this without the world view. --- README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4580ae90f..eb51afc32 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,18 @@ ## Requirements: -- PC version of GTA:V; +For the app: +- PC version of GTA:V, Legacy or Enhanced; - 4GB RAM (8+ recommended); - Windows 7 and above, x64 processor; -- .NET framework 4.5 or newer from [Microsoft](https://www.microsoft.com/net/download/thank-you/net471); +- .NET Framework 4.8 or newer from [Microsoft](https://dotnet.microsoft.com/download/dotnet-framework/net48); - DirectX 11 and Shader Model 4.0 capable graphics. +For the command line program: +- PC version of GTA:V, Legacy or Enhanced; +- Windows or Linux; +- .NET Framework 4.8, .NET 8 or .NET 10. + # App Usage: On first startup, the app will prompt to browse for the GTA:V game folder. If you have the Steam version installed in the default location `(C:\Program Files (x86)\Steam\SteamApps\common\Grand Theft Auto V)`, then this step will be skipped automatically. @@ -33,6 +39,9 @@ view is not needed, and the world loading can be avoided. To activate the menu m # Explorer Mode: The app can be started with the `'explorer'` command line argument. This displays an interface much like OpenIV, with a Windows-Explorer style interface for browsing the game's .rpf archives. Double-click on files to open them. Viewers for most file types are available, but hex view will be shown as a fallback. To activate the explorer mode, run the command: CodeWalker.exe explorer. Alternatively, run the CodeWalker Explorer batch file in the program's directory. +# Command Line: +CodeWalker.Cli is a separate console program for working with archives without the graphical interface. It can list, extract, search, pack, compare and validate `RPF` archives, export files to XML, DDS, WAV and text, convert files to enhanced (Gen9) format, and generate Jenkins hashes. Every command takes a `--json` option and writes a single object to standard output, for use from a script. Run `CodeWalker.Cli --help` for the list of commands, and `CodeWalker.Cli --help` for a command's options, its JSON fields and its exit codes. + # Main Toolbar: The main toolbar is used to access most of the editing features in CodeWalker. Shortcuts for new, open and create files are provided. The selection mode can be changed with the "pointer" button. Move, rotate and scale buttons provide access to the different editing widget modes. Other shortcuts on the toolbar include buttons to open the Selection Info window, and the Project window. See the tooltips on the toolbar items for hints.