diff --git a/README.md b/README.md index 3ce33e4..fe7da85 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![Release downloads](https://img.shields.io/github/downloads/thegraydot/mpqcli/total?label=release_downloads) ![Package downloads](https://img.shields.io/badge/package_downloads-996-green) -A command-line tool to create, add, remove, list, extract, read, and verify MPQ archives using the [StormLib library](https://github.com/ladislav-zezula/StormLib). +A command-line tool to create, add, remove, list, extract, read, rename, and verify MPQ archives using the [StormLib library](https://github.com/ladislav-zezula/StormLib). > ⚠️ **Warning:** This project is under active development and will change functionality between released versions until version 1.0.0. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index dd8d3d9..b8d03a0 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -10,6 +10,7 @@ - [create](./commands/create.md) - [add](./commands/add.md) - [remove](./commands/remove.md) + - [rename](./commands/rename.md) - [list](./commands/list.md) - [extract](./commands/extract.md) - [read](./commands/read.md) diff --git a/docs/commands/rename.md b/docs/commands/rename.md new file mode 100644 index 0000000..81975cf --- /dev/null +++ b/docs/commands/rename.md @@ -0,0 +1,19 @@ +# rename + +Rename a file in an existing MPQ archive without extracting and re-adding it. +The archive is the first positional argument, followed by the current and new archive paths. +This operation does not cause MPQ fragmentation, and thus it is not necessary to +[`compact`](compact.md) the archive. + +```bash +$ mpqcli rename wow-patch.mpq old-name.txt new-name.txt +[~] Renaming file: old-name.txt -> new-name.txt +``` + +Use `--locale` to rename only the copy stored under a specific locale. Without `--locale`, +the file stored under the default locale is renamed. + +```bash +$ mpqcli rename wow-patch.mpq old-name.txt new-name.txt --locale esES +[~] Renaming file for locale esES: old-name.txt -> new-name.txt +``` diff --git a/docs/commands_list.md b/docs/commands_list.md index 93c1de5..a40e8a5 100644 --- a/docs/commands_list.md +++ b/docs/commands_list.md @@ -14,6 +14,7 @@ The `mpqcli` program has the following subcommands: | [`create`](./commands/create.md) | Create an MPQ archive from a target directory or a single file | | [`add`](./commands/add.md) | Add a file to an existing MPQ archive | | [`remove`](./commands/remove.md) | Remove a file from an existing MPQ archive | +| [`rename`](./commands/rename.md) | Rename a file in an existing MPQ archive | | [`list`](./commands/list.md) | List files in a target MPQ archive | | [`extract`](./commands/extract.md) | Extract one or all files from a target MPQ archive | | [`read`](./commands/read.md) | Read a specific file to stdout | diff --git a/docs/introduction.md b/docs/introduction.md index 10c81c6..c0a4e1f 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -1,6 +1,6 @@ # Introduction -A command-line tool to create, add, remove, list, extract, read, verify, and compact MPQ archives using the [StormLib library](https://github.com/ladislav-zezula/StormLib). +A command-line tool to create, add, remove, list, extract, read, rename, verify, and compact MPQ archives using the [StormLib library](https://github.com/ladislav-zezula/StormLib). > ⚠️ **Warning:** This project is under active development and will change functionality between released versions until version 1.0.0. diff --git a/src/commands.cpp b/src/commands.cpp index 14e7de0..11c6495 100644 --- a/src/commands.cpp +++ b/src/commands.cpp @@ -241,6 +241,19 @@ int HandleRemove(const std::vector &files, const std::string &targe return overall_result; } +int HandleRename(const std::string &old_file, const std::string &new_file, + const std::string &target, const std::optional &locale) { + HANDLE archive; + if (!OpenMpqArchive(target, &archive, 0)) { + return 1; + } + + LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; + int result = RenameFile(archive, old_file, new_file, lcid); + CloseMpqArchive(archive); + return result; +} + int HandleList(const std::string &target, const std::optional &listfile_name, bool list_all, bool list_detailed, const std::vector &properties) { HANDLE archive; diff --git a/src/commands.h b/src/commands.h index 3b23f44..27bb95f 100644 --- a/src/commands.h +++ b/src/commands.h @@ -23,6 +23,8 @@ int HandleAdd(const std::vector &files, const std::string &target, int64_t file_compression, int64_t file_compression_next); int HandleRemove(const std::vector &files, const std::string &target, const std::optional &locale); +int HandleRename(const std::string &old_file, const std::string &new_file, + const std::string &target, const std::optional &locale); int HandleList(const std::string &target, const std::optional &listfile_name, bool list_all, bool list_detailed, const std::vector &properties); int HandleExtract(const std::string &target, const std::optional &output, diff --git a/src/completion/mpqcli.bash b/src/completion/mpqcli.bash index 6624dd4..cc247da 100644 --- a/src/completion/mpqcli.bash +++ b/src/completion/mpqcli.bash @@ -29,7 +29,7 @@ _mpqcli() { cword=$COMP_CWORD fi - local subcommands="version about info create add remove list extract read verify compact completion" + local subcommands="version about info create add remove rename list extract read verify compact completion" local -a locales=( default enUS zhTW csCZ deDE esES frFR itIT jaJP koKR nlNL plPL ptBR ruRU zhCN enGB esMX ptPT @@ -141,6 +141,21 @@ _mpqcli() { fi ;; + rename) + case "$prev" in + --locale) + mapfile -t COMPREPLY < <(compgen -W "${locales[*]}" -- "$cur") + return ;; + esac + if [[ "$cur" == -* ]]; then + mapfile -t COMPREPLY < <(compgen -W "--locale" -- "$cur") + elif [[ $cword -eq 2 ]]; then + # positional 1 is the archive; positionals 2 and 3 are in-archive + # paths, so they get no filesystem completion + _mpqcli_filedir mpq + fi + ;; + list) case "$prev" in -l|--listfile) diff --git a/src/completion/mpqcli.fish b/src/completion/mpqcli.fish index cec6666..b51bbf5 100644 --- a/src/completion/mpqcli.fish +++ b/src/completion/mpqcli.fish @@ -40,29 +40,31 @@ set -l __mpqcli_list_properties \ flags encryption-key encryption-key-raw # Top-level subcommands (no subcommand active yet) -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove list extract read verify compact completion' \ +complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ -a version -d 'Print program version' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove list extract read verify compact completion' \ +complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ -a about -d 'Print program information' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove list extract read verify compact completion' \ +complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ -a info -d 'Print info about an MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove list extract read verify compact completion' \ +complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ -a create -d 'Create an MPQ archive from a file or directory' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove list extract read verify compact completion' \ +complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ -a add -d 'Add files to an existing MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove list extract read verify compact completion' \ +complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ -a remove -d 'Remove files from an existing MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove list extract read verify compact completion' \ +complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ + -a rename -d 'Rename a file in an existing MPQ archive' +complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ -a list -d 'List files in an MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove list extract read verify compact completion' \ +complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ -a extract -d 'Extract files from an MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove list extract read verify compact completion' \ +complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ -a read -d 'Read a file from an MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove list extract read verify compact completion' \ +complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ -a verify -d 'Verify an MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove list extract read verify compact completion' \ +complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ -a compact -d 'Compact the MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove list extract read verify compact completion' \ +complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ -a completion -d 'Generate shell completion script' # info @@ -151,6 +153,14 @@ complete -c mpqcli -n '__fish_seen_subcommand_from remove' \ -l locale -d 'Locale of the file to remove' \ -r -a "$__mpqcli_locales" +# rename +# rename [--locale] +complete -c mpqcli -n '__fish_seen_subcommand_from rename' -F + +complete -c mpqcli -n '__fish_seen_subcommand_from rename' \ + -l locale -d 'Locale of the file to rename' \ + -r -a "$__mpqcli_locales" + # list # list [-l/--listfile] [-d/--detailed] [-a/--all] # [-p/--property ] diff --git a/src/completion/mpqcli.ps1 b/src/completion/mpqcli.ps1 index cd651ac..72c7910 100644 --- a/src/completion/mpqcli.ps1 +++ b/src/completion/mpqcli.ps1 @@ -28,6 +28,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl 'create' = 'Create an MPQ archive from target file or directory' 'add' = 'Add files to an existing MPQ archive' 'remove' = 'Remove files from an existing MPQ archive' + 'rename' = 'Rename a file in an existing MPQ archive' 'list' = 'List files from the MPQ archive' 'extract' = 'Extract files from the MPQ archive' 'read' = 'Read a file from an MPQ archive' @@ -117,6 +118,9 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl 'remove' = @{ '--locale' = 'Locale of file to remove' } + 'rename' = @{ + '--locale' = 'Locale of file to rename' + } 'list' = @{ '-l' = 'File listing content of an MPQ archive' '--listfile' = 'File listing content of an MPQ archive' diff --git a/src/completion/mpqcli.zsh b/src/completion/mpqcli.zsh index 183aa7c..31519f4 100644 --- a/src/completion/mpqcli.zsh +++ b/src/completion/mpqcli.zsh @@ -49,6 +49,7 @@ _mpqcli() { create) _mpqcli_create ;; add) _mpqcli_add ;; remove) _mpqcli_remove ;; + rename) _mpqcli_rename ;; list) _mpqcli_list ;; extract) _mpqcli_extract ;; read) _mpqcli_read ;; @@ -68,6 +69,7 @@ _mpqcli_cmds() { 'create:Create an MPQ archive from target file or directory' 'add:Add files to an existing MPQ archive' 'remove:Remove files from an existing MPQ archive' + 'rename:Rename a file in an existing MPQ archive' 'list:List files from the MPQ archive' 'extract:Extract files from the MPQ archive' 'read:Read a file from an MPQ archive' @@ -127,6 +129,14 @@ _mpqcli_remove() { '--locale[locale of file to remove]:locale:('"${_mpqcli_locales[@]}"')' } +_mpqcli_rename() { + _arguments \ + '1:archive:_files' \ + '2:old archive path' \ + '3:new archive path' \ + '--locale[locale of file to rename]:locale:("${_mpqcli_locales[@]}")' +} + _mpqcli_list() { local props="${_mpqcli_list_properties[*]}" _arguments \ diff --git a/src/main.cpp b/src/main.cpp index 796a3d0..39e7ae3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -13,7 +13,8 @@ int main(int argc, char **argv) { CLI::App app{ - "A command line tool to create, add, remove, list, extract, read, and verify MPQ archives " + "A command line tool to create, add, remove, list, extract, read, rename, and verify MPQ " + "archives " "using the StormLib library"}; app.require_subcommand(1); @@ -40,6 +41,8 @@ int main(int argc, char **argv) { std::vector add_files; // CLI: remove std::vector remove_files; + // CLI: rename + std::string rename_new_file; // CLI: extract bool extract_keep_folder_structure = false; // CLI: create @@ -193,6 +196,15 @@ int main(int argc, char **argv) { ->expected(-1); remove->add_option("--locale", base_locale, "Locale of file to remove")->check(locale_valid); + // Subcommand: Rename + CLI::App *rename = app.add_subcommand("rename", "Rename a file in an existing MPQ archive"); + rename->add_option("archive", base_target, "Target MPQ archive") + ->required() + ->check(CLI::ExistingFile); + rename->add_option("old-file", base_file, "Current archive path of the file")->required(); + rename->add_option("new-file", rename_new_file, "New archive path of the file")->required(); + rename->add_option("--locale", base_locale, "Locale of file to rename")->check(locale_valid); + // Subcommand: List CLI::App *list = app.add_subcommand("list", "List files from the MPQ archive"); list->add_option("target", base_target, "Target MPQ archive") @@ -319,6 +331,10 @@ int main(int argc, char **argv) { return HandleRemove(resolved_remove_files, base_target, base_locale); } + if (app.got_subcommand(rename)) { + return HandleRename(base_file, rename_new_file, base_target, base_locale); + } + if (app.got_subcommand(list)) { return HandleList(base_target, base_listfile_name, list_all, list_detailed, list_properties); diff --git a/src/mpq.cpp b/src/mpq.cpp index 93b9cf2..1a613e6 100644 --- a/src/mpq.cpp +++ b/src/mpq.cpp @@ -364,6 +364,30 @@ int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale return 0; } +int RenameFile(HANDLE archive, const std::string &old_archive_file_path, + const std::string &new_archive_file_path, LCID locale) { + SFileSetLocale(locale); + const std::string new_stored_path = WindowsifyFilePath(new_archive_file_path); + std::cout << "[~] Renaming file" << PrettyPrintLocale(locale, " for locale ") << ": " + << old_archive_file_path << " -> " << new_stored_path << std::endl; + + if (!FileExistsInArchiveForLocale(archive, old_archive_file_path, locale)) { + std::cerr << "[!] Failed: File doesn't exist" + << PrettyPrintLocale(locale, " for locale ", true) << ": " + << old_archive_file_path << std::endl; + return 1; + } + + if (!SFileRenameFile(archive, old_archive_file_path.c_str(), new_stored_path.c_str())) { + std::cerr << "[!] Failed: File cannot be renamed" + << PrettyPrintLocale(locale, " for locale ", true) << ": " + << old_archive_file_path << " -> " << new_stored_path << std::endl; + return 1; + } + + return 0; +} + std::string GetFlagString(uint32_t flags) { std::string result; diff --git a/src/mpq.h b/src/mpq.h index 767d623..7c6fc41 100644 --- a/src/mpq.h +++ b/src/mpq.h @@ -30,6 +30,8 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), bool overwrite = false); int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale); +int RenameFile(HANDLE archive, const std::string &old_archive_file_path, + const std::string &new_archive_file_path, LCID locale); int ListFiles(HANDLE archive, const std::optional &listfile_name, bool list_all, bool list_detailed, const std::vector &properties); std::unique_ptr ReadFile(HANDLE archive, const char *file_name, unsigned int *file_size, diff --git a/test/test_completion.py b/test/test_completion.py index 9914232..545f9c5 100644 --- a/test/test_completion.py +++ b/test/test_completion.py @@ -126,3 +126,25 @@ def test_completion_invalid_shell(binary_path): assert result.returncode != 0, "Expected non-zero exit code for unsupported shell" assert result.stderr, "Expected error output for unsupported shell" + + +def test_completion_scripts_cover_every_subcommand(binary_path): + """Every subcommand must appear in all four completion scripts. A new + subcommand wired into only some of them is the failure this guards against.""" + subcommands = [ + "version", "about", "info", "create", "add", "remove", + "rename", "list", "extract", "read", "verify", "compact", + "completion", + ] + + for shell in ["bash", "zsh", "powershell", "fish"]: + result = subprocess.run( + [str(binary_path), "completion", shell], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + + missing = [s for s in subcommands if s not in result.stdout] + assert not missing, f"{shell} completion is missing subcommands: {missing}" diff --git a/test/test_rename.py b/test/test_rename.py new file mode 100644 index 0000000..191a7d1 --- /dev/null +++ b/test/test_rename.py @@ -0,0 +1,397 @@ +import subprocess +from pathlib import Path + + +def test_rename_file_in_mpq_archive(binary_path, generate_mpq_without_internal_listfile): + script_dir = Path(__file__).parent + target_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" + listfile = script_dir / "data" / "listfile.txt" + listfile.write_text("cats.txt\ndogs.txt\ncapybaras.txt\nrenamed.txt\n") + + result = subprocess.run( + [str(binary_path), "rename", str(target_file), "capybaras.txt", "renamed.txt"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert "[~] Renaming file: capybaras.txt -> renamed.txt" in result.stdout + + result = subprocess.run( + [str(binary_path), "list", str(target_file), "--listfile", str(listfile)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + assert result.returncode == 0 + assert "renamed.txt" in result.stdout + assert "capybaras.txt" not in result.stdout + + +def test_rename_missing_file_fails(binary_path, generate_mpq_without_internal_listfile): + target_file = Path(__file__).parent / "data" / "mpq_without_internal_listfile.mpq" + result = subprocess.run( + [str(binary_path), "rename", str(target_file), "missing.txt", "renamed.txt"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + + assert result.returncode == 1 + assert "[!] Failed: File doesn't exist" in result.stderr + + +def test_rename_target_mpq_does_not_exist(binary_path, generate_locales_mpq_test_files): + """ + Test MPQ file rename with a non-existent target. + + This test checks: + - If the application exits correctly when the target does not exist. + """ + _ = generate_locales_mpq_test_files + script_dir = Path(__file__).parent + test_file = "cats.txt" + target_file = script_dir / "does" / "not" / "exist.mpq" + + result = subprocess.run( + [str(binary_path), "rename", str(target_file), test_file, "renamed.txt"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 105, f"mpqcli failed with error: {result.stderr}" + + +def test_rename_target_file_does_not_exist(binary_path, generate_locales_mpq_test_files): + """ + Test MPQ file rename with a non-existent file to rename. + + This test checks: + - If the application exits correctly when the target file to rename does not exist. + """ + _ = generate_locales_mpq_test_files + script_dir = Path(__file__).parent + test_file = "does-not-exist.txt" + target_file = script_dir / "data" / "mpq_with_many_locales.mpq" + + result = subprocess.run( + [str(binary_path), "rename", str(target_file), test_file, "renamed.txt"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + output_lines = set(result.stdout.splitlines()) + expected_stdout_output = { + "[~] Renaming file: does-not-exist.txt -> renamed.txt", + } + assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" + + output_lines = set(result.stderr.splitlines()) + expected_stderr_output = { + "[!] Failed: File doesn't exist for locale enUS: does-not-exist.txt", + } + assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" + + assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" + + +def test_rename_file_from_mpq_archive_with_wrong_locale_given( + binary_path, + generate_locales_mpq_test_files, + generate_mpq_without_internal_listfile, +): + """ + Test MPQ file rename, with the wrong locale given. + + This test checks: + - When the user gives a locale that does not exist for the file, + the file is not renamed. + """ + _ = generate_locales_mpq_test_files + _ = generate_mpq_without_internal_listfile + script_dir = Path(__file__).parent + + permutations = [ + ("capybaras.txt", "mpq_without_internal_listfile.mpq"), # File exists only for the Default locale + ("cats.txt", "mpq_without_internal_listfile.mpq"), # File exists only for the German locale + ("dogs.txt", "mpq_without_internal_listfile.mpq"), # File exists only for the Swedish locale (which is not in locales.cpp) + ("cats.txt", "mpq_with_many_locales.mpq"), # File exists for many locales + ] + + for test_file, target_file_name in permutations: + target_file = script_dir / "data" / target_file_name + + result = subprocess.run( + [str(binary_path), "rename", str(target_file), test_file, "renamed.txt", "--locale", "ptPT"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + output_lines = set(result.stdout.splitlines()) + expected_output = { + "[~] Renaming file for locale ptPT: " + test_file + " -> renamed.txt", + } + assert output_lines == expected_output, f"Unexpected output: {output_lines}" + + output_lines = set(result.stderr.splitlines()) + expected_stderr_output = { + "[!] Failed: File doesn't exist for locale ptPT: " + test_file, + } + assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" + + assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" + + +def test_rename_default_locale_file_from_mpq_archive_unique_name(binary_path, generate_mpq_without_internal_listfile): + """ + Test MPQ file rename, with no locale given. + + This test checks: + - When there is only one file with the same name and default locale, + it should be renamed, when no locale is given by the user. + """ + _ = generate_mpq_without_internal_listfile + script_dir = Path(__file__).parent + test_file = "capybaras.txt" + target_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" + + result = subprocess.run( + [str(binary_path), "rename", str(target_file), test_file, "renamed.txt"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + output_lines = set(result.stdout.splitlines()) + expected_output = { + "[~] Renaming file: capybaras.txt -> renamed.txt", + } + assert output_lines == expected_output, f"Unexpected output: {output_lines}" + + output_lines = set(result.stderr.splitlines()) + expected_stderr_output = set() + assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + + +def test_rename_files_from_mpq_archive_shared_name(binary_path, generate_locales_mpq_test_files): + """ + Test MPQ file rename with locale, for files sharing the same name across locales. + + This test checks: + - If the application correctly handles renaming a file with + the given locale from an MPQ archive, without affecting the other + locale copies that share the same name. + """ + _ = generate_locales_mpq_test_files + script_dir = Path(__file__).parent + test_file = "cats.txt" + target_file = script_dir / "data" / "mpq_with_many_locales.mpq" + + expected_output = { + "enUS cats.txt", + "deDE cats.txt", + "esES cats.txt", + "041D cats.txt", + } + verify_archive_content(binary_path, target_file, expected_output) + + # Renaming without specifying locale means renaming using locale 0 = enUS + result = subprocess.run( + [str(binary_path), "rename", str(target_file), test_file, "renamed_enus.txt"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + + expected_output = { + "enUS renamed_enus.txt", + "deDE cats.txt", + "esES cats.txt", + "041D cats.txt", + } + verify_archive_content(binary_path, target_file, expected_output) + + + result = subprocess.run( + [str(binary_path), "rename", str(target_file), test_file, "renamed_eses.txt", "--locale", "esES"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + + expected_output = { + "enUS renamed_enus.txt", + "deDE cats.txt", + "esES renamed_eses.txt", + "041D cats.txt", + } + verify_archive_content(binary_path, target_file, expected_output) + + + result = subprocess.run( + [str(binary_path), "rename", str(target_file), test_file, "renamed_041d.txt", "--locale", "041D"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + + expected_output = { + "enUS renamed_enus.txt", + "deDE cats.txt", + "esES renamed_eses.txt", + "041D renamed_041d.txt", + } + verify_archive_content(binary_path, target_file, expected_output) + + +def test_rename_files_from_mpq_archive_unique_name(binary_path, generate_mpq_without_internal_listfile): + """ + Test MPQ file rename with locale, for a file with a unique name stored + under a non-default locale. + + This test checks: + - If the application correctly handles renaming a file with + the given locale from an MPQ archive. + """ + _ = generate_mpq_without_internal_listfile + script_dir = Path(__file__).parent + test_file = "dogs.txt" + target_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" + listfile = script_dir / "data" / "listfile.txt" + listfile.write_text("cats.txt\ndogs.txt\ncapybaras.txt\nrenamed.txt") + + expected_output = { + "enUS capybaras.txt", + "deDE cats.txt", + "041D dogs.txt", + } + verify_archive_content(binary_path, target_file, expected_output, listfile) + + # Renaming without specifying locale means renaming using locale 0 = enUS; + # dogs.txt only exists for locale 041D, so this should fail. + result = subprocess.run( + [str(binary_path), "rename", str(target_file), test_file, "renamed.txt"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" + + expected_output = { + "enUS capybaras.txt", + "deDE cats.txt", + "041D dogs.txt", + } + verify_archive_content(binary_path, target_file, expected_output, listfile) + + + result = subprocess.run( + [str(binary_path), "rename", str(target_file), test_file, "renamed.txt", "--locale", "041D"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + + expected_output = { + "enUS capybaras.txt", + "deDE cats.txt", + "041D renamed.txt", + } + verify_archive_content(binary_path, target_file, expected_output, listfile) + + +def test_rename_to_existing_name_fails(binary_path, generate_mpq_without_internal_listfile, tmp_path): + """ + Test MPQ file rename to a name that already exists for the same locale. + + This test checks: + - If the application fails, and leaves the archive unchanged, when + renaming a file to a name that is already used by another file + under the same locale. + """ + _ = generate_mpq_without_internal_listfile + script_dir = Path(__file__).parent + target_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" + + # Add a second file under the default locale, so that "capybaras.txt" + # (also default locale) has a name collision to rename into. + extra_file = tmp_path / "elephants.txt" + extra_file.write_text("This is a file about elephants.\n", newline="\n") + + result = subprocess.run( + [str(binary_path), "add", str(target_file), str(extra_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + + result = subprocess.run( + [str(binary_path), "rename", str(target_file), "capybaras.txt", "elephants.txt"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 1, f"mpqcli unexpectedly succeeded: {result.stdout}" + assert "[~] Renaming file: capybaras.txt -> elephants.txt" in result.stdout + assert "[!] Failed: File cannot be renamed for locale enUS: capybaras.txt -> elephants.txt" in result.stderr + + # Verify the archive is unchanged: both files should still exist under their original names. + listfile = script_dir / "data" / "listfile.txt" + listfile.write_text("cats.txt\ndogs.txt\ncapybaras.txt\nelephants.txt") + + result = subprocess.run( + [str(binary_path), "list", str(target_file), "--listfile", str(listfile)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0 + assert "capybaras.txt" in result.stdout + assert "elephants.txt" in result.stdout + + +def verify_archive_content(binary_path, target_file, expected_output, listfile=Path()): + # Verify that the archive has the expected content + cmd = [str(binary_path), "list", "-d", str(target_file), "-p", "locale"] + if listfile != Path(): + cmd.extend(["--listfile", str(listfile)]) + + result = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + output_lines = set(result.stdout.splitlines()) + assert output_lines == expected_output, f"Unexpected output: {output_lines}" + + +def test_rename_converts_forward_slashes_to_backslashes(binary_path, generate_mpq_without_internal_listfile): + """A new name containing forward slashes is stored using the backslash + separator that MPQ archives use, matching how add and create store paths.""" + script_dir = Path(__file__).parent + target_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" + listfile = script_dir / "data" / "listfile.txt" + listfile.write_text("cats.txt\ndogs.txt\ncapybaras.txt\ndeep\\renamed.txt\n") + + result = subprocess.run( + [str(binary_path), "rename", str(target_file), "capybaras.txt", "deep/renamed.txt"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert "[~] Renaming file: capybaras.txt -> deep\\renamed.txt" in result.stdout + + result = subprocess.run( + [str(binary_path), "list", str(target_file), "--listfile", str(listfile)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + assert result.returncode == 0 + assert "deep\\renamed.txt" in result.stdout + assert "deep/renamed.txt" not in result.stdout