From d97e0b94a578a147786cfdaa341d8c4a82c7ebaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Sj=C3=B6blom?= Date: Tue, 18 Aug 2026 23:07:31 +0200 Subject: [PATCH 1/4] Implementing rename subcommand --- README.md | 2 +- docs/SUMMARY.md | 1 + docs/commands/rename.md | 19 ++ docs/commands_list.md | 1 + src/commands.cpp | 13 ++ src/commands.h | 2 + src/completion/mpqcli.fish | 8 +- src/completion/mpqcli.zsh | 10 + src/main.cpp | 15 ++ src/mpq.cpp | 23 +++ src/mpq.h | 2 + test/test_rename.py | 372 +++++++++++++++++++++++++++++++++++++ 12 files changed, 464 insertions(+), 4 deletions(-) create mode 100644 docs/commands/rename.md create mode 100644 test/test_rename.py 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..5b1967b --- /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.html) 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/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.fish b/src/completion/mpqcli.fish index cec6666..3a901f7 100644 --- a/src/completion/mpqcli.fish +++ b/src/completion/mpqcli.fish @@ -40,7 +40,7 @@ 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' \ -a about -d 'Print program information' @@ -50,9 +50,11 @@ complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create -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' \ -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' \ -a extract -d 'Extract files from 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..95a7b93 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -40,6 +40,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 +195,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 +330,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..807c06d 100644 --- a/src/mpq.cpp +++ b/src/mpq.cpp @@ -364,6 +364,29 @@ 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); + std::cout << "[~] Renaming file" << PrettyPrintLocale(locale, " for locale ") << ": " + << old_archive_file_path << " -> " << new_archive_file_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_archive_file_path.c_str())) { + std::cerr << "[!] Failed: File cannot be renamed" + << PrettyPrintLocale(locale, " for locale ", true) << ": " + << old_archive_file_path << " -> " << new_archive_file_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_rename.py b/test/test_rename.py new file mode 100644 index 0000000..fd0af00 --- /dev/null +++ b/test/test_rename.py @@ -0,0 +1,372 @@ +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}" From fc43db403f4bf6b773df39333813b17f849fb322 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Wed, 19 Aug 2026 23:28:23 +1200 Subject: [PATCH 2/4] Fixed rename not converting path separators in the new file name --- src/mpq.cpp | 7 ++++--- test/test_rename.py | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/mpq.cpp b/src/mpq.cpp index 807c06d..1a613e6 100644 --- a/src/mpq.cpp +++ b/src/mpq.cpp @@ -367,8 +367,9 @@ 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) { 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_archive_file_path << std::endl; + << old_archive_file_path << " -> " << new_stored_path << std::endl; if (!FileExistsInArchiveForLocale(archive, old_archive_file_path, locale)) { std::cerr << "[!] Failed: File doesn't exist" @@ -377,10 +378,10 @@ int RenameFile(HANDLE archive, const std::string &old_archive_file_path, return 1; } - if (!SFileRenameFile(archive, old_archive_file_path.c_str(), new_archive_file_path.c_str())) { + 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_archive_file_path << std::endl; + << old_archive_file_path << " -> " << new_stored_path << std::endl; return 1; } diff --git a/test/test_rename.py b/test/test_rename.py index fd0af00..191a7d1 100644 --- a/test/test_rename.py +++ b/test/test_rename.py @@ -370,3 +370,28 @@ def verify_archive_content(binary_path, target_file, expected_output, listfile=P ) 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 From 1b19c89d5f017428c18e04b61b50b78b71a94b89 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Wed, 19 Aug 2026 23:31:50 +1200 Subject: [PATCH 3/4] Added missing rename completions for bash, fish and PowerShell --- src/completion/mpqcli.bash | 17 ++++++++++++++++- src/completion/mpqcli.fish | 26 +++++++++++++++++--------- src/completion/mpqcli.ps1 | 4 ++++ test/test_completion.py | 22 ++++++++++++++++++++++ 4 files changed, 59 insertions(+), 10 deletions(-) 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 3a901f7..b51bbf5 100644 --- a/src/completion/mpqcli.fish +++ b/src/completion/mpqcli.fish @@ -42,13 +42,13 @@ set -l __mpqcli_list_properties \ # Top-level subcommands (no subcommand active yet) 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 rename list extract read verify compact completion' \ -a remove -d 'Remove files from an existing MPQ archive' @@ -56,15 +56,15 @@ complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create -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 @@ -153,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/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}" From 8c52c6ce716a6cf19b8a63990af072990f11e0b9 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Wed, 19 Aug 2026 23:34:36 +1200 Subject: [PATCH 4/4] Added rename to help text and command lists, fixed docs link --- docs/commands/rename.md | 2 +- docs/introduction.md | 2 +- src/main.cpp | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/commands/rename.md b/docs/commands/rename.md index 5b1967b..81975cf 100644 --- a/docs/commands/rename.md +++ b/docs/commands/rename.md @@ -3,7 +3,7 @@ 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.html) the archive. +[`compact`](compact.md) the archive. ```bash $ mpqcli rename wow-patch.mpq old-name.txt new-name.txt 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/main.cpp b/src/main.cpp index 95a7b93..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);