Repository: conan-io/conan

Issue:
[feature] Adding support to exclude pattern when removing files with Conan tools
### What is your suggestion?

Greetings!

Following the logic behind of [conan.tools.files.copy](https://docs.conan.io/2/reference/tools/files/basic.html#conan-tools-files-copy), users can exclude an specific pattern of file, avoid extra logic in the recipe to copy or not files.

The same could be added to [conan.tools.files.rm](https://docs.conan.io/2/reference/tools/files/basic.html#conan-tools-files-rm) too.

The scenario is when you have mixed files in the same folder, but only want to remove part of them. Real example, exclude Tools installed in bindir, but keep .dll installed at same place.

Currently we have the Geographiclib in ConanCenterIndex under to this specific case, we need to exclude tools after installing (there is an option for tools, and upstream recommends customizing the install, not the building step), but preserving .dll files when having it.

### Have you read the CONTRIBUTING guide?

- [x] I've read the CONTRIBUTING guide


Discussion:
Sure, if this helps recipes, I think this can be added. 

Patch:
diff --git a/conan/tools/files/files.py b/conan/tools/files/files.py
--- a/conan/tools/files/files.py
+++ b/conan/tools/files/files.py
@@ -66,7 +66,7 @@ def rmdir(conanfile, path):
     _internal_rmdir(path)
 
 
-def rm(conanfile, pattern, folder, recursive=False):
+def rm(conanfile, pattern, folder, recursive=False, excludes=None):
     """
     Utility functions to remove files matching a ``pattern`` in a ``folder``.
 
@@ -74,10 +74,17 @@ def rm(conanfile, pattern, folder, recursive=False):
     :param pattern: Pattern that the files to be removed have to match (fnmatch).
     :param folder: Folder to search/remove the files.
     :param recursive: If ``recursive`` is specified it will search in the subfolders.
+    :param excludes: (Optional, defaulted to None) A tuple/list of fnmatch patterns or even a
+                     single one to be excluded from the remove pattern.
     """
+    if excludes and not isinstance(excludes, (tuple, list)):
+        excludes = (excludes,)
+    elif not excludes:
+        excludes = []
+
     for root, _, filenames in os.walk(folder):
         for filename in filenames:
-            if fnmatch(filename, pattern):
+            if fnmatch(filename, pattern) and not any(fnmatch(filename, it) for it in excludes):
                 fullname = os.path.join(root, filename)
                 os.unlink(fullname)
         if not recursive:


Test patch:
diff --git a/test/unittests/tools/files/test_rm.py b/test/unittests/tools/files/test_rm.py
--- a/test/unittests/tools/files/test_rm.py
+++ b/test/unittests/tools/files/test_rm.py
@@ -1,4 +1,5 @@
 import os
+import pytest
 
 # Check it is importable from tools
 from conan.tools.files import rm, chdir
@@ -63,3 +64,46 @@ def test_remove_files_by_mask_non_recursively():
 
     assert os.path.exists(os.path.join(tmpdir, "1.txt"))
     assert os.path.exists(os.path.join(tmpdir, "subdir", "2.txt"))
+
+
+@pytest.mark.parametrize("recursive", [False, True])
+@pytest.mark.parametrize("results", [
+    ["*.dll", ("foo.dll",)],
+    [("*.dll",), ("foo.dll",)],
+    [["*.dll"], ("foo.dll",)],
+    [("*.dll", "*.lib"), ("foo.dll", "foo.dll.lib")],
+])
+def test_exclude_pattern_from_remove_list(recursive, results):
+    """ conan.tools.files.rm should not remove files that match the pattern but are excluded
+        by the excludes parameter.
+        It should obey the recursive parameter, only excluding the files in the root folder in case
+        it is False.
+    """
+    excludes, expected_files = results
+    temporary_folder = temp_folder()
+    with chdir(None, temporary_folder):
+        os.makedirs("subdir")
+
+    save_files(temporary_folder, {
+        "1.txt": "",
+        "1.pdb": "",
+        "1.pdb1": "",
+        "foo.dll": "",
+        "foo.dll.lib": "",
+        os.path.join("subdir", "2.txt"): "",
+        os.path.join("subdir", "2.pdb"): "",
+        os.path.join("subdir", "foo.dll"): "",
+        os.path.join("subdir", "foo.dll.lib"): "",
+        os.path.join("subdir", "2.pdb1"): ""})
+
+    rm(None, "*", temporary_folder, excludes=excludes, recursive=recursive)
+
+    for it in expected_files:
+        assert os.path.exists(os.path.join(temporary_folder, it))
+    assert not os.path.exists(os.path.join(temporary_folder, "1.pdb"))
+
+    # Check the recursive parameter and subfolder
+    condition = (lambda x: not x) if recursive else (lambda x: x)
+    assert condition(os.path.exists(os.path.join(temporary_folder, "subdir", "2.pdb")))
+    for it in expected_files:
+        assert os.path.exists(os.path.join(temporary_folder, "subdir", it))