mirror of
https://github.com/oven-sh/bun
synced 2026-02-17 06:12:08 +00:00
Compare commits
7 Commits
claude/fix
...
claude/imp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b79406878 | ||
|
|
fad01ae8d8 | ||
|
|
f57454afea | ||
|
|
1f1fb2c582 | ||
|
|
deae5c3d61 | ||
|
|
6c348f4a81 | ||
|
|
7e73c0dda8 |
@@ -152,6 +152,31 @@ const response = await fetch("https://bun.com");
|
||||
await Bun.write("index.html", response);
|
||||
```
|
||||
|
||||
## Renaming files (`Bun.rename()`)
|
||||
|
||||
`Bun.rename(from, to, conflict?): Promise<void>`
|
||||
|
||||
Atomically rename or move files and directories. Similar to Node.js `fs.promises.rename()` but with an optional third parameter for conflict resolution.
|
||||
|
||||
```ts
|
||||
// Basic rename (replaces destination if exists)
|
||||
await Bun.rename("old.txt", "new.txt");
|
||||
|
||||
// Atomic swap (Linux/macOS only, falls back to replace on Windows)
|
||||
await Bun.rename("file1.txt", "file2.txt", "swap");
|
||||
|
||||
// Fail if destination exists
|
||||
await Bun.rename("source.txt", "dest.txt", "no-replace");
|
||||
```
|
||||
|
||||
The `conflict` parameter controls behavior when the destination already exists:
|
||||
|
||||
| Mode | Description |
|
||||
| -------------- | -------------------------------------------------------------------------------------- |
|
||||
| `"replace"` | Replace destination if it exists (default) |
|
||||
| `"swap"` | Atomically swap the two files (Linux/macOS only, falls back to `"replace"` on Windows) |
|
||||
| `"no-replace"` | Fail with an error if destination exists |
|
||||
|
||||
---
|
||||
|
||||
## Incremental writing with `FileSink`
|
||||
|
||||
33
packages/bun-types/bun.d.ts
vendored
33
packages/bun-types/bun.d.ts
vendored
@@ -1163,6 +1163,39 @@ declare module "bun" {
|
||||
},
|
||||
): Promise<number>;
|
||||
|
||||
/**
|
||||
* Atomically rename a file or directory from `from` to `to`.
|
||||
*
|
||||
* Similar to Node.js's `fs.promises.rename()`, but with an additional optional `conflict` parameter
|
||||
* to control what happens when the destination already exists.
|
||||
*
|
||||
* On Windows, the `conflict` parameter behaves differently due to platform limitations:
|
||||
* - `"swap"` falls back to `"replace"` (atomic swap is not supported)
|
||||
* - `"no-replace"` may not be fully atomic (checks existence then renames)
|
||||
*
|
||||
* @param from - The current file or directory path
|
||||
* @param to - The new file or directory path
|
||||
* @param conflict - How to handle conflicts when destination exists:
|
||||
* - `"replace"` (default) - Replace the destination if it exists
|
||||
* - `"swap"` - Atomically swap the files (Linux/macOS only, falls back to replace on Windows)
|
||||
* - `"no-replace"` - Fail if destination already exists
|
||||
*
|
||||
* @returns A promise that resolves when the rename is complete
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Basic rename (replaces destination if it exists)
|
||||
* await Bun.rename("old.txt", "new.txt");
|
||||
*
|
||||
* // Atomically swap two files (Linux/macOS only)
|
||||
* await Bun.rename("file1.txt", "file2.txt", "swap");
|
||||
*
|
||||
* // Fail if destination exists
|
||||
* await Bun.rename("source.txt", "dest.txt", "no-replace");
|
||||
* ```
|
||||
*/
|
||||
function rename(from: PathLike, to: PathLike, conflict?: "replace" | "swap" | "no-replace"): Promise<void>;
|
||||
|
||||
interface SystemError extends Error {
|
||||
errno?: number | undefined;
|
||||
code?: string | undefined;
|
||||
|
||||
@@ -28,6 +28,7 @@ pub const BunObject = struct {
|
||||
pub const nanoseconds = toJSCallback(Bun.nanoseconds);
|
||||
pub const openInEditor = toJSCallback(Bun.openInEditor);
|
||||
pub const registerMacro = toJSCallback(Bun.registerMacro);
|
||||
pub const rename = toJSCallback(Bun.rename);
|
||||
pub const resolve = toJSCallback(Bun.resolve);
|
||||
pub const resolveSync = toJSCallback(Bun.resolveSync);
|
||||
pub const serve = toJSCallback(Bun.serve);
|
||||
@@ -171,6 +172,7 @@ pub const BunObject = struct {
|
||||
@export(&BunObject.nanoseconds, .{ .name = callbackName("nanoseconds") });
|
||||
@export(&BunObject.openInEditor, .{ .name = callbackName("openInEditor") });
|
||||
@export(&BunObject.registerMacro, .{ .name = callbackName("registerMacro") });
|
||||
@export(&BunObject.rename, .{ .name = callbackName("rename") });
|
||||
@export(&BunObject.resolve, .{ .name = callbackName("resolve") });
|
||||
@export(&BunObject.resolveSync, .{ .name = callbackName("resolveSync") });
|
||||
@export(&BunObject.serve, .{ .name = callbackName("serve") });
|
||||
@@ -762,6 +764,219 @@ pub fn sleepSync(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) b
|
||||
return .js_undefined;
|
||||
}
|
||||
|
||||
const RenameConflict = enum {
|
||||
replace,
|
||||
swap,
|
||||
no_replace,
|
||||
|
||||
pub fn fromJS(globalThis: *jsc.JSGlobalObject, value: jsc.JSValue) bun.JSError!?RenameConflict {
|
||||
if (value.isEmptyOrUndefinedOrNull()) return null;
|
||||
if (!value.isString()) {
|
||||
return globalThis.throwInvalidArgumentType("rename", "conflict", "string");
|
||||
}
|
||||
const str = try value.toSlice(globalThis, bun.default_allocator);
|
||||
defer str.deinit();
|
||||
|
||||
if (strings.eqlComptime(str.slice(), "replace")) {
|
||||
return .replace;
|
||||
} else if (strings.eqlComptime(str.slice(), "swap")) {
|
||||
return .swap;
|
||||
} else if (strings.eqlComptime(str.slice(), "no-replace")) {
|
||||
return .no_replace;
|
||||
} else {
|
||||
return globalThis.throwInvalidArguments("conflict must be 'replace', 'swap', or 'no-replace'", .{});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const RenameResultType = bun.sys.Maybe(void);
|
||||
|
||||
const RenameJob = struct {
|
||||
from_path: [:0]u8,
|
||||
to_path: [:0]u8,
|
||||
conflict: RenameConflict,
|
||||
task: jsc.WorkPoolTask = .{ .callback = &runRenameTask },
|
||||
promise: jsc.JSPromise.Strong = .{},
|
||||
vm: *jsc.VirtualMachine,
|
||||
result: RenameResultType = .{ .result = {} },
|
||||
completion_task: jsc.AnyTask = undefined,
|
||||
|
||||
pub fn create(globalThis: *JSGlobalObject, from_path: [:0]u8, to_path: [:0]u8, conflict: RenameConflict) *RenameJob {
|
||||
const job = bun.new(RenameJob, .{
|
||||
.from_path = from_path,
|
||||
.to_path = to_path,
|
||||
.conflict = conflict,
|
||||
.vm = globalThis.bunVM(),
|
||||
});
|
||||
job.completion_task = jsc.AnyTask.New(RenameJob, finalize).init(job);
|
||||
return job;
|
||||
}
|
||||
|
||||
pub fn runRenameTask(task: *jsc.WorkPoolTask) void {
|
||||
const job: *RenameJob = @fieldParentPtr("task", task);
|
||||
|
||||
job.result = performRename(job.from_path, job.to_path, job.conflict);
|
||||
|
||||
// Clone the error to avoid UAF when path buffers are freed
|
||||
switch (job.result) {
|
||||
.err => |*err| {
|
||||
job.result = .{ .err = err.clone(bun.default_allocator) };
|
||||
},
|
||||
.result => {},
|
||||
}
|
||||
|
||||
bun.default_allocator.free(job.from_path);
|
||||
bun.default_allocator.free(job.to_path);
|
||||
|
||||
// Schedule completion on main thread
|
||||
job.vm.enqueueTask(jsc.Task.init(&job.completion_task));
|
||||
}
|
||||
|
||||
pub fn finalize(job: *RenameJob) void {
|
||||
var promise = job.promise.swap();
|
||||
const globalThis = job.vm.global;
|
||||
|
||||
switch (job.result) {
|
||||
.err => |*err| {
|
||||
defer err.deinit();
|
||||
const error_instance = globalThis.createErrorInstance("rename failed: errno {d}", .{err.errno});
|
||||
promise.reject(globalThis, error_instance) catch {};
|
||||
},
|
||||
.result => {
|
||||
promise.resolve(globalThis, jsc.JSValue.js_undefined) catch {};
|
||||
},
|
||||
}
|
||||
|
||||
bun.destroy(job);
|
||||
}
|
||||
|
||||
fn performRename(from_path: [:0]const u8, to_path: [:0]const u8, conflict: RenameConflict) RenameResultType {
|
||||
switch (conflict) {
|
||||
.replace => {
|
||||
return bun.sys.renameat(
|
||||
bun.FD.cwd(),
|
||||
from_path,
|
||||
bun.FD.cwd(),
|
||||
to_path,
|
||||
);
|
||||
},
|
||||
.swap => {
|
||||
if (comptime Environment.isWindows) {
|
||||
// Windows doesn't support atomic swap, fall back to replace
|
||||
return bun.sys.renameat(
|
||||
bun.FD.cwd(),
|
||||
from_path,
|
||||
bun.FD.cwd(),
|
||||
to_path,
|
||||
);
|
||||
} else {
|
||||
// Try atomic exchange first
|
||||
const result = bun.sys.renameat2(
|
||||
bun.FD.cwd(),
|
||||
from_path,
|
||||
bun.FD.cwd(),
|
||||
to_path,
|
||||
.{ .exchange = true },
|
||||
);
|
||||
|
||||
// If exchange fails because destination doesn't exist, fall back to regular rename
|
||||
switch (result) {
|
||||
.err => |err| {
|
||||
if (err.getErrno() == .NOENT) {
|
||||
return bun.sys.renameat(
|
||||
bun.FD.cwd(),
|
||||
from_path,
|
||||
bun.FD.cwd(),
|
||||
to_path,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
.result => return result,
|
||||
}
|
||||
}
|
||||
},
|
||||
.no_replace => {
|
||||
if (comptime Environment.isWindows) {
|
||||
// Windows doesn't have RENAME_NOREPLACE equivalent, so we check first
|
||||
switch (bun.sys.exists(to_path)) {
|
||||
.result => |exists| {
|
||||
if (exists) {
|
||||
return .{ .err = bun.sys.Error.fromCode(.EXIST, .rename) };
|
||||
}
|
||||
},
|
||||
.err => {},
|
||||
}
|
||||
return bun.sys.renameat(
|
||||
bun.FD.cwd(),
|
||||
from_path,
|
||||
bun.FD.cwd(),
|
||||
to_path,
|
||||
);
|
||||
} else {
|
||||
return bun.sys.renameat2(
|
||||
bun.FD.cwd(),
|
||||
from_path,
|
||||
bun.FD.cwd(),
|
||||
to_path,
|
||||
.{ .exclude = true },
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pub fn rename(globalThis: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
|
||||
const arguments = callframe.arguments();
|
||||
var args = jsc.CallFrame.ArgumentsSlice.init(globalThis.bunVM(), arguments);
|
||||
defer args.deinit();
|
||||
|
||||
// Parse from path
|
||||
var from_path_like = try jsc.Node.PathOrFileDescriptor.fromJS(globalThis, &args, bun.default_allocator) orelse {
|
||||
return globalThis.throwInvalidArgumentType("rename", "from", "string or Buffer");
|
||||
};
|
||||
defer from_path_like.deinit();
|
||||
|
||||
// Parse to path
|
||||
var to_path_like = try jsc.Node.PathOrFileDescriptor.fromJS(globalThis, &args, bun.default_allocator) orelse {
|
||||
return globalThis.throwInvalidArgumentType("rename", "to", "string or Buffer");
|
||||
};
|
||||
defer to_path_like.deinit();
|
||||
|
||||
// Parse optional conflict parameter
|
||||
const conflict_value = args.nextEat();
|
||||
const conflict = try RenameConflict.fromJS(globalThis, conflict_value orelse jsc.JSValue.js_undefined) orelse .replace;
|
||||
|
||||
// Only support path-based operations for now
|
||||
if (from_path_like != .path or to_path_like != .path) {
|
||||
return globalThis.throwInvalidArguments("rename only supports string paths currently", .{});
|
||||
}
|
||||
|
||||
// Convert paths to null-terminated strings
|
||||
var from_buf: bun.PathBuffer = undefined;
|
||||
var to_buf: bun.PathBuffer = undefined;
|
||||
|
||||
const from_slice = from_path_like.path.sliceZ(&from_buf);
|
||||
const to_slice = to_path_like.path.sliceZ(&to_buf);
|
||||
|
||||
// Duplicate the paths for the async task
|
||||
const from_owned = try bun.default_allocator.dupeZ(u8, from_slice);
|
||||
const to_owned = try bun.default_allocator.dupeZ(u8, to_slice);
|
||||
|
||||
// Create and schedule the rename job
|
||||
const job = RenameJob.create(globalThis, from_owned, to_owned, conflict);
|
||||
|
||||
var promise = JSPromise.create(globalThis);
|
||||
const promise_value = promise.asValue(globalThis);
|
||||
promise_value.ensureStillAlive();
|
||||
job.promise.strong.set(globalThis, promise_value);
|
||||
|
||||
jsc.WorkPool.schedule(&job.task);
|
||||
|
||||
return promise_value;
|
||||
}
|
||||
|
||||
pub const gc = Bun__gc;
|
||||
export fn Bun__gc(vm: *jsc.VirtualMachine, sync: bool) callconv(.c) usize {
|
||||
return vm.garbageCollect(sync);
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
macro(nanoseconds) \
|
||||
macro(openInEditor) \
|
||||
macro(registerMacro) \
|
||||
macro(rename) \
|
||||
macro(resolve) \
|
||||
macro(resolveSync) \
|
||||
macro(serve) \
|
||||
|
||||
@@ -977,6 +977,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
|
||||
readableStreamToJSON JSBuiltin Builtin|Function 1
|
||||
readableStreamToText JSBuiltin Builtin|Function 1
|
||||
registerMacro BunObject_callback_registerMacro DontEnum|DontDelete|Function 1
|
||||
rename BunObject_callback_rename DontDelete|Function 1
|
||||
resolve BunObject_callback_resolve DontDelete|Function 1
|
||||
resolveSync BunObject_callback_resolveSync DontDelete|Function 1
|
||||
revision constructBunRevision ReadOnly|DontDelete|PropertyCallback
|
||||
|
||||
384
test/js/bun/bun-object/rename.test.ts
Normal file
384
test/js/bun/bun-object/rename.test.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import { tmpdirSync } from "harness";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
describe("Bun.rename()", () => {
|
||||
let tmpdir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
tmpdir = tmpdirSync("bun-rename");
|
||||
});
|
||||
|
||||
it("throws when no arguments are provided", async () => {
|
||||
// @ts-expect-error
|
||||
await expect(() => Bun.rename()).toThrowWithCodeAsync(Error, "ERR_INVALID_ARG_TYPE");
|
||||
});
|
||||
|
||||
it("throws when only one argument is provided", async () => {
|
||||
// @ts-expect-error
|
||||
await expect(() => Bun.rename("from.txt")).toThrowWithCodeAsync(Error, "ERR_INVALID_ARG_TYPE");
|
||||
});
|
||||
|
||||
it.each([undefined, null, 1, true, Symbol("foo"), {}])("throws when `from` is not a path (%p)", async (from: any) => {
|
||||
// @ts-expect-error
|
||||
await expect(() => Bun.rename(from, "to.txt")).toThrowWithCodeAsync(Error, "ERR_INVALID_ARG_TYPE");
|
||||
});
|
||||
|
||||
it.each([undefined, null, 1, true, Symbol("foo"), {}])("throws when `to` is not a path (%p)", async (to: any) => {
|
||||
// @ts-expect-error
|
||||
await expect(() => Bun.rename("from.txt", to)).toThrowWithCodeAsync(Error, "ERR_INVALID_ARG_TYPE");
|
||||
});
|
||||
|
||||
it("throws when conflict parameter is invalid", async () => {
|
||||
const from = path.join(tmpdir, "from.txt");
|
||||
const to = path.join(tmpdir, "to.txt");
|
||||
|
||||
await fs.writeFile(from, "content");
|
||||
|
||||
// @ts-expect-error
|
||||
expect(() => Bun.rename(from, to, "invalid")).toThrow("conflict must be 'replace', 'swap', or 'no-replace'");
|
||||
|
||||
// Clean up
|
||||
await fs.unlink(from).catch(() => {});
|
||||
});
|
||||
|
||||
describe("basic file operations", () => {
|
||||
it("renames a file successfully", async () => {
|
||||
const from = path.join(tmpdir, "file-to-rename.txt");
|
||||
const to = path.join(tmpdir, "renamed-file.txt");
|
||||
const content = "Hello, rename!";
|
||||
|
||||
await fs.writeFile(from, content);
|
||||
|
||||
await expect(Bun.rename(from, to)).resolves.toBeUndefined();
|
||||
|
||||
// Verify the file was moved
|
||||
await expect(fs.access(from)).rejects.toThrow();
|
||||
await expect(fs.readFile(to, "utf8")).resolves.toBe(content);
|
||||
|
||||
// Clean up
|
||||
await fs.unlink(to).catch(() => {});
|
||||
});
|
||||
|
||||
it("renames a directory successfully", async () => {
|
||||
const from = path.join(tmpdir, "dir-to-rename");
|
||||
const to = path.join(tmpdir, "renamed-dir");
|
||||
const fileName = "test-file.txt";
|
||||
const content = "Directory content";
|
||||
|
||||
await fs.mkdir(from);
|
||||
await fs.writeFile(path.join(from, fileName), content);
|
||||
|
||||
await expect(Bun.rename(from, to)).resolves.toBeUndefined();
|
||||
|
||||
// Verify the directory was moved
|
||||
await expect(fs.access(from)).rejects.toThrow();
|
||||
await expect(fs.readFile(path.join(to, fileName), "utf8")).resolves.toBe(content);
|
||||
|
||||
// Clean up
|
||||
await fs.rm(to, { recursive: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it("works with Buffer paths", async () => {
|
||||
const from = path.join(tmpdir, "buffer-from.txt");
|
||||
const to = path.join(tmpdir, "buffer-to.txt");
|
||||
const content = "Buffer path test";
|
||||
|
||||
await fs.writeFile(from, content);
|
||||
|
||||
await expect(Bun.rename(Buffer.from(from), Buffer.from(to))).resolves.toBeUndefined();
|
||||
|
||||
// Verify the file was moved
|
||||
await expect(fs.access(from)).rejects.toThrow();
|
||||
await expect(fs.readFile(to, "utf8")).resolves.toBe(content);
|
||||
|
||||
// Clean up
|
||||
await fs.unlink(to).catch(() => {});
|
||||
});
|
||||
|
||||
it("throws when source file doesn't exist", async () => {
|
||||
const from = path.join(tmpdir, "nonexistent.txt");
|
||||
const to = path.join(tmpdir, "destination.txt");
|
||||
|
||||
await expect(Bun.rename(from, to)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("conflict resolution", () => {
|
||||
describe("replace mode (default)", () => {
|
||||
it("replaces existing destination by default", async () => {
|
||||
const from = path.join(tmpdir, "replace-source.txt");
|
||||
const to = path.join(tmpdir, "replace-dest.txt");
|
||||
const sourceContent = "Source content";
|
||||
const destContent = "Original dest content";
|
||||
|
||||
await fs.writeFile(from, sourceContent);
|
||||
await fs.writeFile(to, destContent);
|
||||
|
||||
await expect(Bun.rename(from, to)).resolves.toBeUndefined();
|
||||
|
||||
// Verify the destination has the source content
|
||||
await expect(fs.access(from)).rejects.toThrow();
|
||||
await expect(fs.readFile(to, "utf8")).resolves.toBe(sourceContent);
|
||||
|
||||
// Clean up
|
||||
await fs.unlink(to).catch(() => {});
|
||||
});
|
||||
|
||||
it("replaces existing destination with explicit 'replace'", async () => {
|
||||
const from = path.join(tmpdir, "explicit-replace-source.txt");
|
||||
const to = path.join(tmpdir, "explicit-replace-dest.txt");
|
||||
const sourceContent = "Source content";
|
||||
const destContent = "Original dest content";
|
||||
|
||||
await fs.writeFile(from, sourceContent);
|
||||
await fs.writeFile(to, destContent);
|
||||
|
||||
await expect(Bun.rename(from, to, "replace")).resolves.toBeUndefined();
|
||||
|
||||
// Verify the destination has the source content
|
||||
await expect(fs.access(from)).rejects.toThrow();
|
||||
await expect(fs.readFile(to, "utf8")).resolves.toBe(sourceContent);
|
||||
|
||||
// Clean up
|
||||
await fs.unlink(to).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("no-replace mode", () => {
|
||||
it("fails when destination exists with 'no-replace'", async () => {
|
||||
const from = path.join(tmpdir, "no-replace-source.txt");
|
||||
const to = path.join(tmpdir, "no-replace-dest.txt");
|
||||
const sourceContent = "Source content";
|
||||
const destContent = "Original dest content";
|
||||
|
||||
await fs.writeFile(from, sourceContent);
|
||||
await fs.writeFile(to, destContent);
|
||||
|
||||
await expect(Bun.rename(from, to, "no-replace")).rejects.toThrow();
|
||||
|
||||
// Verify both files still exist with original content
|
||||
await expect(fs.readFile(from, "utf8")).resolves.toBe(sourceContent);
|
||||
await expect(fs.readFile(to, "utf8")).resolves.toBe(destContent);
|
||||
|
||||
// Clean up
|
||||
await fs.unlink(from).catch(() => {});
|
||||
await fs.unlink(to).catch(() => {});
|
||||
});
|
||||
|
||||
it("succeeds when destination doesn't exist with 'no-replace'", async () => {
|
||||
const from = path.join(tmpdir, "no-replace-success-source.txt");
|
||||
const to = path.join(tmpdir, "no-replace-success-dest.txt");
|
||||
const content = "Source content";
|
||||
|
||||
await fs.writeFile(from, content);
|
||||
|
||||
await expect(Bun.rename(from, to, "no-replace")).resolves.toBeUndefined();
|
||||
|
||||
// Verify the file was moved
|
||||
await expect(fs.access(from)).rejects.toThrow();
|
||||
await expect(fs.readFile(to, "utf8")).resolves.toBe(content);
|
||||
|
||||
// Clean up
|
||||
await fs.unlink(to).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("swap mode", () => {
|
||||
it("atomically swaps two files with 'swap'", async () => {
|
||||
const file1 = path.join(tmpdir, "swap-file1.txt");
|
||||
const file2 = path.join(tmpdir, "swap-file2.txt");
|
||||
const content1 = "Content of file 1";
|
||||
const content2 = "Content of file 2";
|
||||
|
||||
await fs.writeFile(file1, content1);
|
||||
await fs.writeFile(file2, content2);
|
||||
|
||||
await expect(Bun.rename(file1, file2, "swap")).resolves.toBeUndefined();
|
||||
|
||||
// Verify the files were swapped
|
||||
await expect(fs.readFile(file1, "utf8")).resolves.toBe(content2);
|
||||
await expect(fs.readFile(file2, "utf8")).resolves.toBe(content1);
|
||||
|
||||
// Clean up
|
||||
await fs.unlink(file1).catch(() => {});
|
||||
await fs.unlink(file2).catch(() => {});
|
||||
});
|
||||
|
||||
it("works when destination doesn't exist with 'swap'", async () => {
|
||||
const from = path.join(tmpdir, "swap-from-only.txt");
|
||||
const to = path.join(tmpdir, "swap-to-nonexistent.txt");
|
||||
const content = "Source content";
|
||||
|
||||
await fs.writeFile(from, content);
|
||||
|
||||
await expect(Bun.rename(from, to, "swap")).resolves.toBeUndefined();
|
||||
|
||||
// Verify the file was moved (not swapped since destination didn't exist)
|
||||
await expect(fs.access(from)).rejects.toThrow();
|
||||
await expect(fs.readFile(to, "utf8")).resolves.toBe(content);
|
||||
|
||||
// Clean up
|
||||
await fs.unlink(to).catch(() => {});
|
||||
});
|
||||
|
||||
it("swaps directories on Unix", async () => {
|
||||
if (process.platform === "win32") {
|
||||
// Skip this test on Windows
|
||||
return;
|
||||
}
|
||||
const dir1 = path.join(tmpdir, "swap-dir1");
|
||||
const dir2 = path.join(tmpdir, "swap-dir2");
|
||||
const file1Content = "Content in dir 1";
|
||||
const file2Content = "Content in dir 2";
|
||||
|
||||
await fs.mkdir(dir1);
|
||||
await fs.mkdir(dir2);
|
||||
await fs.writeFile(path.join(dir1, "file.txt"), file1Content);
|
||||
await fs.writeFile(path.join(dir2, "file.txt"), file2Content);
|
||||
|
||||
await expect(Bun.rename(dir1, dir2, "swap")).resolves.toBeUndefined();
|
||||
|
||||
// Verify the directories were swapped
|
||||
await expect(fs.readFile(path.join(dir1, "file.txt"), "utf8")).resolves.toBe(file2Content);
|
||||
await expect(fs.readFile(path.join(dir2, "file.txt"), "utf8")).resolves.toBe(file1Content);
|
||||
|
||||
// Clean up
|
||||
await fs.rm(dir1, { recursive: true }).catch(() => {});
|
||||
await fs.rm(dir2, { recursive: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it("falls back to replace on Windows when using swap", async () => {
|
||||
const from = path.join(tmpdir, "win-swap-source.txt");
|
||||
const to = path.join(tmpdir, "win-swap-dest.txt");
|
||||
const sourceContent = "Source content";
|
||||
const destContent = "Dest content";
|
||||
|
||||
await fs.writeFile(from, sourceContent);
|
||||
await fs.writeFile(to, destContent);
|
||||
|
||||
// On Windows, swap should fall back to replace behavior
|
||||
// On Unix, it should actually swap the files
|
||||
await expect(Bun.rename(from, to, "swap")).resolves.toBeUndefined();
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// Verify destination has source content (replace behavior on Windows)
|
||||
await expect(fs.access(from)).rejects.toThrow();
|
||||
await expect(fs.readFile(to, "utf8")).resolves.toBe(sourceContent);
|
||||
} else {
|
||||
// Verify files were swapped (Unix behavior)
|
||||
await expect(fs.readFile(from, "utf8")).resolves.toBe(destContent);
|
||||
await expect(fs.readFile(to, "utf8")).resolves.toBe(sourceContent);
|
||||
// Clean up both files
|
||||
await fs.unlink(from).catch(() => {});
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await fs.unlink(to).catch(() => {});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles relative paths", async () => {
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(tmpdir);
|
||||
|
||||
try {
|
||||
const from = "relative-from.txt";
|
||||
const to = "relative-to.txt";
|
||||
const content = "Relative path test";
|
||||
|
||||
await fs.writeFile(from, content);
|
||||
|
||||
await expect(Bun.rename(from, to)).resolves.toBeUndefined();
|
||||
|
||||
// Verify the file was moved
|
||||
await expect(fs.access(from)).rejects.toThrow();
|
||||
await expect(fs.readFile(to, "utf8")).resolves.toBe(content);
|
||||
|
||||
// Clean up
|
||||
await fs.unlink(to).catch(() => {});
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles paths with special characters", async () => {
|
||||
const from = path.join(tmpdir, "file with spaces & symbols!.txt");
|
||||
const to = path.join(tmpdir, "renamed file with spaces & symbols!.txt");
|
||||
const content = "Special chars test";
|
||||
|
||||
await fs.writeFile(from, content);
|
||||
|
||||
await expect(Bun.rename(from, to)).resolves.toBeUndefined();
|
||||
|
||||
// Verify the file was moved
|
||||
await expect(fs.access(from)).rejects.toThrow();
|
||||
await expect(fs.readFile(to, "utf8")).resolves.toBe(content);
|
||||
|
||||
// Clean up
|
||||
await fs.unlink(to).catch(() => {});
|
||||
});
|
||||
|
||||
it("handles empty files", async () => {
|
||||
const from = path.join(tmpdir, "empty-from.txt");
|
||||
const to = path.join(tmpdir, "empty-to.txt");
|
||||
|
||||
await fs.writeFile(from, "");
|
||||
|
||||
await expect(Bun.rename(from, to)).resolves.toBeUndefined();
|
||||
|
||||
// Verify the file was moved
|
||||
await expect(fs.access(from)).rejects.toThrow();
|
||||
await expect(fs.readFile(to, "utf8")).resolves.toBe("");
|
||||
|
||||
// Clean up
|
||||
await fs.unlink(to).catch(() => {});
|
||||
});
|
||||
|
||||
it("handles large files", async () => {
|
||||
const from = path.join(tmpdir, "large-from.txt");
|
||||
const to = path.join(tmpdir, "large-to.txt");
|
||||
const largeContent = "x".repeat(1024 * 1024); // 1MB
|
||||
|
||||
await fs.writeFile(from, largeContent);
|
||||
|
||||
await expect(Bun.rename(from, to)).resolves.toBeUndefined();
|
||||
|
||||
// Verify the file was moved
|
||||
await expect(fs.access(from)).rejects.toThrow();
|
||||
await expect(fs.readFile(to, "utf8")).resolves.toBe(largeContent);
|
||||
|
||||
// Clean up
|
||||
await fs.unlink(to).catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-platform behavior", () => {
|
||||
it("works across different filesystems when supported", async () => {
|
||||
// This test may fail on some systems if /tmp and current dir are on different filesystems
|
||||
// but that's expected behavior
|
||||
const from = path.join(tmpdir, "cross-fs-source.txt");
|
||||
const to = "/tmp/cross-fs-dest.txt";
|
||||
const content = "Cross filesystem test";
|
||||
|
||||
await fs.writeFile(from, content);
|
||||
|
||||
try {
|
||||
await Bun.rename(from, to);
|
||||
|
||||
// If it succeeds, verify the file was moved
|
||||
await expect(fs.access(from)).rejects.toThrow();
|
||||
await expect(fs.readFile(to, "utf8")).resolves.toBe(content);
|
||||
|
||||
// Clean up
|
||||
await fs.unlink(to).catch(() => {});
|
||||
} catch (error) {
|
||||
// It's okay if this fails due to cross-filesystem limitations
|
||||
// Clean up source file
|
||||
await fs.unlink(from).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user