From 3b75ee7c1925388e01dd69540d51d9387e26dcb0 Mon Sep 17 00:00:00 2001 From: James Bronder <36022278+jbronder@users.noreply.github.com> Date: Mon, 3 Feb 2025 22:18:51 -0800 Subject: [PATCH] feat(fs/unstable): add rename (#6379) --- _tools/node_test_runner/run_test.mjs | 1 + fs/deno.json | 1 + fs/unstable_rename.ts | 44 ++++++ fs/unstable_rename_test.ts | 193 +++++++++++++++++++++++++++ 4 files changed, 239 insertions(+) create mode 100644 fs/unstable_rename.ts create mode 100644 fs/unstable_rename_test.ts diff --git a/_tools/node_test_runner/run_test.mjs b/_tools/node_test_runner/run_test.mjs index 79b10428d9f1..b15cdaeddde8 100644 --- a/_tools/node_test_runner/run_test.mjs +++ b/_tools/node_test_runner/run_test.mjs @@ -53,6 +53,7 @@ import "../../fs/unstable_link_test.ts"; import "../../fs/unstable_read_dir_test.ts"; import "../../fs/unstable_read_link_test.ts"; import "../../fs/unstable_real_path_test.ts"; +import "../../fs/unstable_rename_test.ts"; import "../../fs/unstable_stat_test.ts"; import "../../fs/unstable_symlink_test.ts"; import "../../fs/unstable_lstat_test.ts"; diff --git a/fs/deno.json b/fs/deno.json index e9fa16175f4b..bf24e9531e2e 100644 --- a/fs/deno.json +++ b/fs/deno.json @@ -19,6 +19,7 @@ "./unstable-read-dir": "./unstable_read_dir.ts", "./unstable-read-link": "./unstable_read_link.ts", "./unstable-real-path": "./unstable_real_path.ts", + "./unstable-rename": "./unstable_rename.ts", "./unstable-stat": "./unstable_stat.ts", "./unstable-symlink": "./unstable_symlink.ts", "./unstable-types": "./unstable_types.ts", diff --git a/fs/unstable_rename.ts b/fs/unstable_rename.ts new file mode 100644 index 000000000000..f5911ae1fd99 --- /dev/null +++ b/fs/unstable_rename.ts @@ -0,0 +1,44 @@ +// Copyright 2018-2025 the Deno authors. MIT license. + +import { getNodeFs, isDeno } from "./_utils.ts"; +import { mapError } from "./_map_error.ts"; + +/** + * Renames (moves) `oldpath` to `newpath`. Paths may be files or directories. + * If `newpath` already exists and is not a directory, `rename()` replaces it. + * OS-specific restrictions may apply when `oldpath` and `newpath` are in + * different directories. + * + * On Unix-like OSes, this operation does not follow symlinks at either path. + * + * It varies between platforms when the operation throws errors, and if so + * what they are. It's always an error to rename anything to a non-empty + * directory. + * + * Requires `allow-read` and `allow-write` permissions. + * + * @example Usage + * ```ts ignore + * import { rename } from "@std/fs/unstable-rename"; + * await rename("old/path", "new/path"); + * ``` + * + * @tags allow-read, allow-write + * + * @param oldpath The current name/path of the file/directory. + * @param newpath The updated name/path of the file/directory. + */ +export async function rename( + oldpath: string | URL, + newpath: string | URL, +): Promise { + if (isDeno) { + await Deno.rename(oldpath, newpath); + } else { + try { + await getNodeFs().promises.rename(oldpath, newpath); + } catch (error) { + throw mapError(error); + } + } +} diff --git a/fs/unstable_rename_test.ts b/fs/unstable_rename_test.ts new file mode 100644 index 000000000000..1cae85fe9bb5 --- /dev/null +++ b/fs/unstable_rename_test.ts @@ -0,0 +1,193 @@ +// Copyright 2018-2025 the Deno authors. MIT license. + +import { assert, assertRejects } from "@std/assert"; +import { rename } from "./unstable_rename.ts"; +import { NotFound } from "./unstable_errors.js"; +import { lstatSync } from "node:fs"; +import { mkdir, mkdtemp, open, rm, stat, symlink } from "node:fs/promises"; +import { platform, tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +/** Tests if the original file/directory is missing since the file is renamed. + * Uses Node.js Error instances to check because the `lstatSync` function is + * pulled in from the `node:fs` package without using `mapError`. */ +function assertMissing(path: string) { + let caughtErr = false; + let info; + try { + info = lstatSync(path); + } catch (error) { + caughtErr = true; + // Check if the error caught is a Node.js error instance. + if (error instanceof Error && "code" in error) { + assert(error.code === "ENOENT", "errno code is not ENOENT."); + } + } + assert(caughtErr); + assert(info === undefined); +} + +Deno.test("rename() renames a regular file", async () => { + const tempDirPath = await mkdtemp(resolve(tmpdir(), "rename_")); + const testFile = join(tempDirPath, "testFile.txt"); + const renameFile = join(tempDirPath, "renamedFile.txt"); + + const testFh = await open(testFile, "w"); + await testFh.close(); + + await rename(testFile, renameFile); + assertMissing(testFile); + const renameFileStat = await stat(renameFile); + assert(renameFileStat.isFile()); + + await rm(tempDirPath, { recursive: true, force: true }); +}); + +Deno.test("rename() rejects with Error when an existing regular file is renamed with an existing directory path", async () => { + const tempDirPath = await mkdtemp(resolve(tmpdir(), "rename_")); + const testFile = join(tempDirPath, "testFile.txt"); + const testDir = join(tempDirPath, "testDir"); + + const tempFh = await open(testFile, "w"); + await tempFh.close(); + await mkdir(testDir); + + await assertRejects(async () => { + await rename(testFile, testDir); + }, Error); + + await rm(tempDirPath, { recursive: true, force: true }); +}); + +Deno.test("rename() rejects with Error when an existing directory is renamed with an existing directory containing a file", async () => { + const tempDirPath = await mkdtemp(resolve(tmpdir(), "rename_")); + const emptyDir = join(tempDirPath, "emptyDir"); + const fullDir = join(tempDirPath, "fullDir"); + const testFile = join(fullDir, "testFile.txt"); + + await mkdir(fullDir); + await mkdir(emptyDir); + const testFh = await open(testFile, "w"); + await testFh.close(); + + await assertRejects(async () => { + await rename(emptyDir, fullDir); + }, Error); + + await rm(tempDirPath, { recursive: true, force: true }); +}); + +Deno.test("rename() rejects with Error on Windows and succeeds on *nix when an existing directory is renamed with another directory path", async () => { + const tempDirPath = await mkdtemp(resolve(tmpdir(), "rename_")); + const testDir = join(tempDirPath, "testDir"); + const anotherDir = join(tempDirPath, "anotherDir"); + + await mkdir(testDir); + await mkdir(anotherDir); + + if (platform() === "win32") { + await assertRejects(async () => { + await rename(testDir, anotherDir); + }, Error); + } else { + await rename(testDir, anotherDir); + assertMissing(testDir); + const anotherDirStat = await stat(anotherDir); + assert(anotherDirStat.isDirectory()); + } + + await rm(tempDirPath, { recursive: true, force: true }); +}); + +Deno.test("rename() rejects with Error on *nix and succeeds on Windows when an existing directory is renamed with an existing regular file path", async () => { + const tempDirPath = await mkdtemp(resolve(tmpdir(), "rename_")); + const testFile = join(tempDirPath, "testFile.txt"); + const testDir = join(tempDirPath, "testDir"); + + const testFh = await open(testFile, "w"); + await testFh.close(); + await mkdir(testDir); + + if (platform() === "win32") { + await rename(testDir, testFile); + const fileStat = await stat(testFile); + assert(fileStat.isDirectory()); + } else { + await assertRejects(async () => { + await rename(testDir, testFile); + }, Error); + } + + await rm(tempDirPath, { recursive: true, force: true }); +}); + +Deno.test({ + name: + "rename() rejects with Error when renaming an existing directory with a valid symlink'd regular file path", + ignore: platform() === "win32", + fn: async () => { + const tempDirPath = await mkdtemp(resolve(tmpdir(), "rename_")); + const testDir = join(tempDirPath, "testDir"); + const testFile = join(tempDirPath, "testFile.txt"); + const symlinkFile = join(tempDirPath, "testFile.txt.link"); + + await mkdir(testDir); + const testFh = await open(testFile, "w"); + await testFh.close(); + await symlink(testFile, symlinkFile); + + await assertRejects(async () => { + await rename(testDir, symlinkFile); + }, Error); + + await rm(tempDirPath, { recursive: true, force: true }); + }, +}); + +Deno.test({ + name: + "rename() rejects with Error when renaming an existing directory with a valid symlink'd directory path", + ignore: platform() === "win32", + fn: async () => { + const tempDirPath = await mkdtemp(resolve(tmpdir(), "rename_")); + const testDir = join(tempDirPath, "testDir"); + const anotherDir = join(tempDirPath, "anotherDir"); + const symlinkDir = join(tempDirPath, "symlinkDir"); + + await mkdir(testDir); + await mkdir(anotherDir); + await symlink(anotherDir, symlinkDir); + + await assertRejects(async () => { + await rename(testDir, symlinkDir); + }, Error); + + await rm(tempDirPath, { recursive: true, force: true }); + }, +}); + +Deno.test({ + name: + "rename() rejects with Error when renaming an existing directory with a symlink'd file pointing to a non-existent file path", + ignore: platform() === "win32", + fn: async () => { + const tempDirPath = await mkdtemp(resolve(tmpdir(), "rename_")); + const testDir = join(tempDirPath, "testDir"); + const symlinkPath = join(tempDirPath, "symlinkPath"); + + await mkdir(testDir); + await symlink("non-existent", symlinkPath); + + await assertRejects(async () => { + await rename(testDir, symlinkPath); + }, Error); + + await rm(tempDirPath, { recursive: true, force: true }); + }, +}); + +Deno.test("rename() rejects with NotFound for renaming a non-existent file", async () => { + await assertRejects(async () => { + await rename("non-existent-file.txt", "new-name.txt"); + }, NotFound); +});