Repository: conan-io/conan

Issue:
[feature] Provide 'tools.rename' utility in the new tools namespace 
A new function to rename files/folders that uses current implementation (_robocopy_ for Windows), but will accept the `conanfile` so we can control behavior in the future if needed

```python
def rename(conanfile, src, dst):
   # Mostly copied from current conans.tools.rename
   ...
```




Discussion:

Resolving patch:
diff --git a/conan/tools/files/__init__.py b/conan/tools/files/__init__.py
--- a/conan/tools/files/__init__.py
+++ b/conan/tools/files/__init__.py
@@ -1,2 +1,2 @@
-from conan.tools.files.files import load, save, mkdir, ftp_download, download, get
+from conan.tools.files.files import load, save, mkdir, ftp_download, download, get, rename
 from conan.tools.files.patches import patch, apply_conandata_patches
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
@@ -1,8 +1,10 @@
 import errno
 import os
+import platform
+import subprocess
 
 from conans.client.downloaders.download import run_downloader
-from conans.client.tools.files import unzip
+from conans.client.tools.files import unzip, which
 from conans.errors import ConanException
 from conans.util.files import decode_text, to_file_bytes
 
@@ -150,3 +152,32 @@ def _download_file(file_url):
                 out.warn(message + " Trying another mirror.")
         else:
             raise ConanException("All downloads from ({}) URLs have failed.".format(len(url)))
+
+
+def rename(conanfile, src, dst):
+    """
+    rename a file or folder to avoid "Access is denied" error on Windows
+    :param conanfile: conanfile object
+    :param src: Source file or folder
+    :param dst: Destination file or folder
+    :return: None
+    """
+    # FIXME: This function has been copied from legacy. Needs to fix: which() call and wrap subprocess call.
+    if os.path.exists(dst):
+        raise ConanException("rename {} to {} failed, dst exists.".format(src, dst))
+
+    if platform.system() == "Windows" and which("robocopy") and os.path.isdir(src):
+        # /move Moves files and directories, and deletes them from the source after they are copied.
+        # /e Copies subdirectories. Note that this option includes empty directories.
+        # /ndl Specifies that directory names are not to be logged.
+        # /nfl Specifies that file names are not to be logged.
+        process = subprocess.Popen(["robocopy", "/move", "/e", "/ndl", "/nfl", src, dst],
+                                   stdout=subprocess.PIPE)
+        process.communicate()
+        if process.returncode > 7:  # https://ss64.com/nt/robocopy-exit.html
+            raise ConanException("rename {} to {} failed.".format(src, dst))
+    else:
+        try:
+            os.rename(src, dst)
+        except Exception as err:
+            raise ConanException("rename {} to {} failed: {}".format(src, dst, err))
diff --git a/conans/client/tools/files.py b/conans/client/tools/files.py
--- a/conans/client/tools/files.py
+++ b/conans/client/tools/files.py
@@ -420,6 +420,7 @@ def dos2unix(filepath):
 
 
 def rename(src, dst):
+    # FIXME: Deprecated, use new interface from conan.tools
     """
     rename a file or folder to avoid "Access is denied" error on Windows
     :param src: Source file or folder
@@ -436,7 +437,7 @@ def rename(src, dst):
         process = subprocess.Popen(["robocopy", "/move", "/e", "/ndl", "/nfl", src, dst],
                                    stdout=subprocess.PIPE)
         process.communicate()
-        if process.returncode != 1:
+        if process.returncode > 7:  # https://ss64.com/nt/robocopy-exit.html
             raise ConanException("rename {} to {} failed.".format(src, dst))
     else:
         try:


Test patch:
diff --git a/conans/test/unittests/client/tools/files/rename_test.py b/conans/test/unittests/client/tools/files/rename_test.py
--- a/conans/test/unittests/client/tools/files/rename_test.py
+++ b/conans/test/unittests/client/tools/files/rename_test.py
@@ -64,3 +64,12 @@ def test_rename(self):
             rename(os.path.join("subdir", "1.txt"), "t.txt")
             self.assertTrue(os.path.isfile(os.path.join(tmpdir, "t.txt")))
             self.assertFalse(os.path.isfile(os.path.join(tmpdir, "subdir", "1.txt")))
+
+    def test_rename_empty_folder(self):
+        client = TestClient()
+        old_folder = os.path.join(client.current_folder, "old_folder")
+        os.mkdir(old_folder)
+        new_folder = os.path.join(client.current_folder, "new_folder")
+        rename(old_folder, new_folder)
+        self.assertFalse(os.path.exists(old_folder))
+        self.assertTrue(os.path.exists(new_folder))
diff --git a/conans/test/unittests/tools/files/test_rename.py b/conans/test/unittests/tools/files/test_rename.py
new file mode 100644
--- /dev/null
+++ b/conans/test/unittests/tools/files/test_rename.py
@@ -0,0 +1,30 @@
+import os
+import platform
+import pytest
+
+from conan.tools.files import rename
+from conans.test.utils.mocks import ConanFileMock
+from conans.test.utils.tools import temp_folder, save_files
+
+
+def test_rename_file():
+    conanfile = ConanFileMock()
+    tmp = temp_folder()
+    save_files(tmp, {"file.txt": ""})
+    old_path = os.path.join(tmp, "file.txt")
+    new_path = os.path.join(tmp, "kk.txt")
+    rename(conanfile, old_path, new_path)
+    assert not os.path.exists(old_path)
+    assert os.path.exists(new_path)
+
+
+@pytest.mark.skipif(platform.system() != "Windows", reason="Robocopy only exists in Windows")
+def test_rename_folder_robocopy():
+    conanfile = ConanFileMock()
+    tmp = temp_folder()
+    old_folder = os.path.join(tmp, "old_folder")
+    os.mkdir(old_folder)
+    new_folder = os.path.join(tmp, "new_folder")
+    rename(conanfile, old_folder, new_folder)
+    assert not os.path.exists(old_folder)
+    assert os.path.exists(new_folder)