diff --git a/.changeset/quiet-files-copy-flags.md b/.changeset/quiet-files-copy-flags.md new file mode 100644 index 00000000000..a76b3b4305f --- /dev/null +++ b/.changeset/quiet-files-copy-flags.md @@ -0,0 +1,9 @@ +--- +"effect": minor +"@effect/platform-node-shared": patch +"@effect/platform-node": patch +"@effect/platform-bun": patch +"@effect/platform-deno": patch +--- + +Add copy file mode flags to `FileSystem.copyFile`. diff --git a/packages/effect/src/FileSystem.ts b/packages/effect/src/FileSystem.ts index cfca6f8d515..26e006e8450 100644 --- a/packages/effect/src/FileSystem.ts +++ b/packages/effect/src/FileSystem.ts @@ -107,10 +107,18 @@ export interface FileSystem { ) => Effect.Effect /** * Copy a file from `fromPath` to `toPath`. + * + * **Details** + * + * The `mode` option accepts a bitwise combination of {@link CopyFileFlag} + * values. */ readonly copyFile: ( fromPath: string, - toPath: string + toPath: string, + options?: { + readonly mode?: number | undefined + } ) => Effect.Effect /** * Change the permissions of a file. @@ -619,6 +627,25 @@ export type OpenFlag = | "a+" | "ax+" +/** + * Flags for `FileSystem.copyFile`. + * + * **Details** + * + * Multiple flags can be combined with the bitwise OR operator. + * + * @category models + * @since 4.0.0 + */ +export const CopyFileFlag = { + /** Fail if the destination already exists. */ + COPYFILE_EXCL: 1, + /** Attempt copy-on-write and fall back to a regular copy if unavailable. */ + COPYFILE_FICLONE: 2, + /** Require copy-on-write and fail if unavailable. */ + COPYFILE_FICLONE_FORCE: 4 +} as const + /** * Service tag for platform file-system operations. * diff --git a/packages/platform-deno/src/DenoFileSystem.ts b/packages/platform-deno/src/DenoFileSystem.ts index 69d0df7e642..19ea4699cd3 100644 --- a/packages/platform-deno/src/DenoFileSystem.ts +++ b/packages/platform-deno/src/DenoFileSystem.ts @@ -67,8 +67,20 @@ const copy: FileSystem.FileSystem["copy"] = (fromPath, toPath, options) => preserveTimestamps: options?.preserveTimestamps ?? false })) -const copyFile: FileSystem.FileSystem["copyFile"] = (fromPath, toPath) => - tryPromise("copyFile", fromPath, () => Deno.copyFile(fromPath, toPath)) +const copyFile: FileSystem.FileSystem["copyFile"] = (fromPath, toPath, options) => { + const mode = options?.mode ?? 0 + const { COPYFILE_EXCL, COPYFILE_FICLONE_FORCE } = FileSystem.CopyFileFlag + if ((mode & COPYFILE_EXCL) !== 0 || (mode & COPYFILE_FICLONE_FORCE) !== 0) { + return Effect.fail( + PlatformError.badArgument({ + module: "FileSystem", + method: "copyFile", + description: "The copy file mode is unsupported by Deno" + }) + ) + } + return tryPromise("copyFile", fromPath, () => Deno.copyFile(fromPath, toPath)) +} const chmod: FileSystem.FileSystem["chmod"] = (path, mode) => tryPromise("chmod", path, () => Deno.chmod(path, mode)) @@ -485,6 +497,12 @@ const makeFileSystem = Effect.map(Effect.serviceOption(FileSystem.WatchBackend), /** * Provides the `FileSystem` service backed by Deno filesystem APIs. * + * **Gotchas** + * + * `COPYFILE_FICLONE` falls back to a regular copy. `COPYFILE_EXCL` and + * `COPYFILE_FICLONE_FORCE` fail with a PlatformError.BadArgument` because + * Deno cannot provide their semantics. + * * @category layers * @since 4.0.0 */ diff --git a/packages/platform-deno/test/DenoFileSystem.test.ts b/packages/platform-deno/test/DenoFileSystem.test.ts index 96843f6c246..bee8ed3653a 100644 --- a/packages/platform-deno/test/DenoFileSystem.test.ts +++ b/packages/platform-deno/test/DenoFileSystem.test.ts @@ -1,9 +1,38 @@ import * as DenoFileSystem from "@effect/platform-deno/DenoFileSystem" -import { describe } from "@effect/vitest" +import { assert, describe, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as FileSystem from "effect/FileSystem" import { testLayer } from "../../effect/test/FileSystem.test-utils.ts" -describe("FileSystem", () => +describe("FileSystem", () => { testLayer(DenoFileSystem.layer, { accessOnDirectory: false, tempFileScopedRemovesDirectory: false - })) + }) + + it.effect("copyFile rejects unsupported copy file modes", () => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const root = yield* fs.makeTempDirectoryScoped() + const source = `${root}/source.txt` + const destination = `${root}/destination.txt` + + yield* fs.writeFileString(source, "source") + + const exclusiveError = yield* Effect.flip( + fs.copyFile(source, destination, { mode: FileSystem.CopyFileFlag.COPYFILE_EXCL }) + ) + assert.strictEqual(exclusiveError.reason._tag, "BadArgument") + + yield* fs.copyFile(source, destination, { mode: FileSystem.CopyFileFlag.COPYFILE_FICLONE }) + assert.strictEqual(yield* fs.readFileString(destination), "source") + + const forceError = yield* Effect.flip( + fs.copyFile(source, destination, { mode: FileSystem.CopyFileFlag.COPYFILE_FICLONE_FORCE }) + ) + assert.strictEqual(forceError.reason._tag, "BadArgument") + }).pipe( + Effect.scoped, + Effect.provide(DenoFileSystem.layer) + )) +}) diff --git a/packages/platform-node-shared/src/NodeFileSystem.ts b/packages/platform-node-shared/src/NodeFileSystem.ts index d60489f8772..0cca3caa55b 100644 --- a/packages/platform-node-shared/src/NodeFileSystem.ts +++ b/packages/platform-node-shared/src/NodeFileSystem.ts @@ -70,13 +70,13 @@ const copy = ((): FileSystem.FileSystem["copy"] => { // == copyFile -const copyFile = (() => { +const copyFile: FileSystem.FileSystem["copyFile"] = (() => { const nodeCopyFile = effectify( NFS.copyFile, handleErrnoException("FileSystem", "copyFile"), handleBadArgument("copyFile") ) - return (fromPath: string, toPath: string) => nodeCopyFile(fromPath, toPath) + return (fromPath, toPath, options) => nodeCopyFile(fromPath, toPath, options?.mode ?? 0) })() // == chmod diff --git a/packages/platform-node-shared/test/NodeFileSystem.test.ts b/packages/platform-node-shared/test/NodeFileSystem.test.ts index a0d4d179cf9..f273f6d1c5e 100644 --- a/packages/platform-node-shared/test/NodeFileSystem.test.ts +++ b/packages/platform-node-shared/test/NodeFileSystem.test.ts @@ -44,6 +44,28 @@ const startWatch = ( describe("FileSystem", () => { testLayer(NodeFileSystem.layer) + it.effect("copyFile supports copy file modes", () => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const root = yield* fs.makeTempDirectoryScoped() + const source = `${root}/source.txt` + const destination = `${root}/destination.txt` + + yield* fs.writeFileString(source, "source") + yield* fs.writeFileString(destination, "destination") + + const error = yield* Effect.flip( + fs.copyFile(source, destination, { mode: FileSystem.CopyFileFlag.COPYFILE_EXCL }) + ) + assert.strictEqual(error.reason._tag, "AlreadyExists") + + yield* fs.copyFile(source, destination, { mode: FileSystem.CopyFileFlag.COPYFILE_FICLONE }) + assert.strictEqual(yield* fs.readFileString(destination), "source") + }).pipe( + Effect.scoped, + Effect.provide(NodeFileSystem.layer) + )) + it.effect("watch does not report nested changes when recursive is false", () => Effect.gen(function*() { const fs = yield* FileSystem.FileSystem