flix

0.78.0

FileWrite.flix

/*
 * Copyright 2026 Flix Authors
 *
 * Use of this source code is governed by the Apache 2.0 license
 * that can be found in the LICENSE.md file.
 */

///
/// Effect for modifying files and directories, and its functions.
///
pub mod Fs.FileWrite {

    use Fs.FileStat
    use Fs.FileRead
    use Fs.FileSystem
    use Fs.FileWrite
    use Fs.FsLayer.{logPre, logPre2, logPost, logPost2}
    use Fs.Size
    use Fs.Size.bytes
    use Fs.CopyOption
    use Fs.MoveOption

    ///
    /// Effect for modifying files and directories.
    ///
    /// This includes writing, appending to, truncating, copying, moving, and
    /// deleting files, and creating directories.
    ///
    pub eff FileWrite {

        ///
        /// Writes `str` to the given file `f`.
        ///
        /// Creates file `f` if it does not exist. Overwrites it if it exists.
        ///
        def write(data: {str = String}, f: String): Result[IoError, Unit]

        ///
        /// Writes `lines` to the given file `f`.
        ///
        /// Creates `f` if it does not exist. Overwrites it if it exists.
        ///
        def writeLines(data: {lines = List[String]}, f: String): Result[IoError, Unit]

        ///
        /// Writes `data` to the given file `f`.
        ///
        /// Creates file `f` if it does not exist. Overwrites it if it exists.
        ///
        def writeBytes(data: Vector[Int8], f: String): Result[IoError, Unit]

        ///
        /// Appends `str` to the given file `f`.
        ///
        /// Creates the file `f` if it does not exist.
        ///
        def append(data: {str = String}, f: String): Result[IoError, Unit]

        ///
        /// Appends `lines` to the given file `f`.
        ///
        /// Creates the file `f` if it does not exist.
        ///
        def appendLines(data: {lines = List[String]}, f: String): Result[IoError, Unit]

        ///
        /// Appends `data` to the given file `f`.
        ///
        /// Creates the file `f` if it does not exist.
        ///
        def appendBytes(data: Vector[Int8], f: String): Result[IoError, Unit]

        ///
        /// Truncates the given file `f`.
        ///
        def truncate(f: String): Result[IoError, Unit]

        ///
        /// Copies the file `src` to `dst` with the given `options`.
        ///
        def copyWith(src: {src = String}, dst: String, opts: Set[CopyOption]): Result[IoError, Unit]

        ///
        /// Moves (renames) the file or directory `src` to `dst` with the given `options`.
        ///
        def moveWith(src: {src = String}, dst: String, opts: Set[MoveOption]): Result[IoError, Unit]

        ///
        /// Deletes the given file `f`.
        ///
        /// Fails if the file does not exist.
        ///
        def delete(f: String): Result[IoError, Unit]

        ///
        /// Creates the directory `d`.
        ///
        def mkDir(d: String): Result[IoError, Unit]

        ///
        /// Creates the directory `d` and all its parent directories.
        ///
        def mkDirs(d: String): Result[IoError, Unit]

        ///
        /// Creates a new temporary directory with the given prefix.
        ///
        /// Returns the path to the directory.
        ///
        def mkTempDir(prefix: String): Result[IoError, String]

    }

    ///
    /// Convenience function: copies `src` to `dst` with no options.
    ///
    pub def copy(src: {src = String}, dst: String): Result[IoError, Unit] \ FileWrite =
        Fs.FileWrite.copyWith(src, dst, Set.empty())

    ///
    /// Convenience function: moves `src` to `dst` with no options.
    ///
    pub def move(src: {src = String}, dst: String): Result[IoError, Unit] \ FileWrite =
        Fs.FileWrite.moveWith(src, dst, Set.empty())

    ///
    /// Handles the `FileWrite` effect of the given function `f`.
    ///
    /// In other words, re-interprets the `FileWrite` effect using the `IO` effect.
    ///
    pub def handle(f: a -> b \ ef): a -> b \ (ef - FileWrite) + IO = x ->
        checked_ecast(
            Fs.FileSystem.handle(
                y -> runWithFileSystem(() -> f(y))
            )(x)
        )

    ///
    /// Runs the `FileWrite` effect of the given function `f`.
    ///
    /// In other words, re-interprets the `FileWrite` effect using the `IO` effect.
    ///
    @DefaultHandler
    pub def runWithIO(f: Unit -> a \ ef): a \ (ef - FileWrite) + IO = handle(f)()

    ///
    /// Handles the `FileWrite` effect of the given function `f` using the `FileSystem` effect.
    ///
    pub def runWithFileSystem(f: Unit -> a \ ef): a \ (ef - FileWrite) + FileSystem =
        run { f() } with handler FileWrite {
            def write(data, file, k)        = k(Fs.FileSystem.write(data, file))
            def writeLines(data, file, k)   = k(Fs.FileSystem.writeLines(data, file))
            def writeBytes(data, file, k)   = k(Fs.FileSystem.writeBytes(data, file))
            def append(data, file, k)       = k(Fs.FileSystem.append(data, file))
            def appendLines(data, file, k)  = k(Fs.FileSystem.appendLines(data, file))
            def appendBytes(data, file, k)  = k(Fs.FileSystem.appendBytes(data, file))
            def truncate(file, k)           = k(Fs.FileSystem.truncate(file))
            def copyWith(src, dst, opts, k) = k(Fs.FileSystem.copyWith(src, dst, opts))
            def moveWith(src, dst, opts, k) = k(Fs.FileSystem.moveWith(src, dst, opts))
            def delete(file, k)             = k(Fs.FileSystem.delete(file))
            def mkDir(d, k)                 = k(Fs.FileSystem.mkDir(d))
            def mkDirs(d, k)                = k(Fs.FileSystem.mkDirs(d))
            def mkTempDir(prefix, k)        = k(Fs.FileSystem.mkTempDir(prefix))
        }

    ///
    /// Middleware that intercepts the `FileWrite` effect, logging each operation
    /// and its result via the `Logger` effect.
    ///
    /// Successful operations are logged at `Debug` level; errors at `Warn`.
    ///
    pub def withLogging(f: Unit -> a \ ef): a \ (ef - FileWrite) + { FileWrite, Logger } =
        run { f() } with handler FileWrite {
            def write(data, file, k) = {
                logPre("write", file);
                let result = FileWrite.write(data, file);
                logPost("write", file, _ -> "${String.length(data#str)} chars", result);
                k(result)
            }
            def writeLines(data, file, k) = {
                logPre("writeLines", file);
                let result = FileWrite.writeLines(data, file);
                logPost("writeLines", file, _ -> "${List.length(data#lines)} lines", result);
                k(result)
            }
            def writeBytes(data, file, k) = {
                logPre("writeBytes", file);
                let result = FileWrite.writeBytes(data, file);
                logPost("writeBytes", file, _ -> "${Vector.length(data)} bytes", result);
                k(result)
            }
            def append(data, file, k) = {
                logPre("append", file);
                let result = FileWrite.append(data, file);
                logPost("append", file, _ -> "${String.length(data#str)} chars", result);
                k(result)
            }
            def appendLines(data, file, k) = {
                logPre("appendLines", file);
                let result = FileWrite.appendLines(data, file);
                logPost("appendLines", file, _ -> "${List.length(data#lines)} lines", result);
                k(result)
            }
            def appendBytes(data, file, k) = {
                logPre("appendBytes", file);
                let result = FileWrite.appendBytes(data, file);
                logPost("appendBytes", file, _ -> "${Vector.length(data)} bytes", result);
                k(result)
            }
            def truncate(file, k) = {
                logPre("truncate", file);
                let result = FileWrite.truncate(file);
                logPost("truncate", file, _ -> "done", result);
                k(result)
            }
            def copyWith(src, dst, opts, k) = {
                logPre2("copy", src#src, dst);
                let result = FileWrite.copyWith(src, dst, opts);
                logPost2("copy", src#src, dst, result);
                k(result)
            }
            def moveWith(src, dst, opts, k) = {
                logPre2("move", src#src, dst);
                let result = FileWrite.moveWith(src, dst, opts);
                logPost2("move", src#src, dst, result);
                k(result)
            }
            def delete(file, k) = {
                logPre("delete", file);
                let result = FileWrite.delete(file);
                logPost("delete", file, _ -> "done", result);
                k(result)
            }
            def mkDir(d, k) = {
                logPre("mkDir", d);
                let result = FileWrite.mkDir(d);
                logPost("mkDir", d, _ -> "done", result);
                k(result)
            }
            def mkDirs(d, k) = {
                logPre("mkDirs", d);
                let result = FileWrite.mkDirs(d);
                logPost("mkDirs", d, _ -> "done", result);
                k(result)
            }
            def mkTempDir(prefix, k) = {
                logPre("mkTempDir", prefix);
                let result = FileWrite.mkTempDir(prefix);
                logPost("mkTempDir", prefix, path -> path, result);
                k(result)
            }
        }

    ///
    /// Middleware that intercepts the `FileWrite` effect, resolving all file paths
    /// relative to the given `baseDir` using Java's `Path.resolve` semantics.
    ///
    /// Absolute paths are passed through unchanged. The `mkTempDir` prefix is not resolved.
    ///
    pub def withBaseDir(baseDir: String, f: Unit -> a \ ef): a \ (ef - FileWrite) + FileWrite =
        let r = p -> Fs.FsLayer.resolve(baseDir, p);
        run { f() } with handler FileWrite {
            def write(data, file, k)        = k(FileWrite.write(data, r(file)))
            def writeLines(data, file, k)   = k(FileWrite.writeLines(data, r(file)))
            def writeBytes(data, file, k)   = k(FileWrite.writeBytes(data, r(file)))
            def append(data, file, k)       = k(FileWrite.append(data, r(file)))
            def appendLines(data, file, k)  = k(FileWrite.appendLines(data, r(file)))
            def appendBytes(data, file, k)  = k(FileWrite.appendBytes(data, r(file)))
            def truncate(file, k)           = k(FileWrite.truncate(r(file)))
            def copyWith(src, dst, opts, k) = k(FileWrite.copyWith(src = r(src#src), r(dst), opts))
            def moveWith(src, dst, opts, k) = k(FileWrite.moveWith(src = r(src#src), r(dst), opts))
            def delete(file, k)             = k(FileWrite.delete(r(file)))
            def mkDir(d, k)                 = k(FileWrite.mkDir(r(d)))
            def mkDirs(d, k)                = k(FileWrite.mkDirs(r(d)))
            def mkTempDir(prefix, k)        = k(FileWrite.mkTempDir(prefix))
        }

    ///
    /// Middleware that intercepts the `FileWrite` effect, validating that all file paths
    /// resolve within the given `chrootDir`. Rejects paths that escape the chroot with
    /// a `PermissionDenied` error.
    ///
    /// The `mkTempDir` operation is blocked since it creates directories outside the chroot.
    ///
    pub def withChroot(chrootDir: String, f: Unit -> a \ ef): a \ (ef - FileWrite) + FileWrite =
        use IoError.IoError;
        use IoError.ErrorKind;
        let c = p -> Fs.FsLayer.chroot(chrootDir, p);
        run { f() } with handler FileWrite {
            def write(data, file, k)        = k(Result.flatMap(p -> FileWrite.write(data, p), c(file)))
            def writeLines(data, file, k)   = k(Result.flatMap(p -> FileWrite.writeLines(data, p), c(file)))
            def writeBytes(data, file, k)   = k(Result.flatMap(p -> FileWrite.writeBytes(data, p), c(file)))
            def append(data, file, k)       = k(Result.flatMap(p -> FileWrite.append(data, p), c(file)))
            def appendLines(data, file, k)  = k(Result.flatMap(p -> FileWrite.appendLines(data, p), c(file)))
            def appendBytes(data, file, k)  = k(Result.flatMap(p -> FileWrite.appendBytes(data, p), c(file)))
            def truncate(file, k)           = k(Result.flatMap(p -> FileWrite.truncate(p), c(file)))
            def copyWith(src, dst, opts, k) = k(Result.flatMap(s -> Result.flatMap(d -> FileWrite.copyWith(src = s, d, opts), c(dst)), c(src#src)))
            def moveWith(src, dst, opts, k) = k(Result.flatMap(s -> Result.flatMap(d -> FileWrite.moveWith(src = s, d, opts), c(dst)), c(src#src)))
            def delete(file, k)             = k(Result.flatMap(p -> FileWrite.delete(p), c(file)))
            def mkDir(d, k)                 = k(Result.flatMap(p -> FileWrite.mkDir(p), c(d)))
            def mkDirs(d, k)                = k(Result.flatMap(p -> FileWrite.mkDirs(p), c(d)))
            def mkTempDir(_prefix, k)       = k(Err(IoError(ErrorKind.PermissionDenied, "mkTempDir is not permitted inside a chroot")))
        }

    ///
    /// Middleware that intercepts the `FileWrite` effect, validating that all file paths
    /// resolve within one of the given `allowedDirs`. Rejects paths outside the
    /// allow list with a `PermissionDenied` error.
    ///
    /// The `mkTempDir` operation is blocked since the temp directory location cannot be verified.
    ///
    pub def withAllowList(allowedDirs: Nel[String], f: Unit -> a \ ef): a \ (ef - FileWrite) + FileWrite =
        use IoError.IoError;
        use IoError.ErrorKind;
        let c = p -> Fs.FsLayer.allowList(allowedDirs, p);
        run { f() } with handler FileWrite {
            def write(data, file, k)        = k(Result.flatMap(p -> FileWrite.write(data, p), c(file)))
            def writeLines(data, file, k)   = k(Result.flatMap(p -> FileWrite.writeLines(data, p), c(file)))
            def writeBytes(data, file, k)   = k(Result.flatMap(p -> FileWrite.writeBytes(data, p), c(file)))
            def append(data, file, k)       = k(Result.flatMap(p -> FileWrite.append(data, p), c(file)))
            def appendLines(data, file, k)  = k(Result.flatMap(p -> FileWrite.appendLines(data, p), c(file)))
            def appendBytes(data, file, k)  = k(Result.flatMap(p -> FileWrite.appendBytes(data, p), c(file)))
            def truncate(file, k)           = k(Result.flatMap(p -> FileWrite.truncate(p), c(file)))
            def copyWith(src, dst, opts, k) = k(Result.flatMap(s -> Result.flatMap(d -> FileWrite.copyWith(src = s, d, opts), c(dst)), c(src#src)))
            def moveWith(src, dst, opts, k) = k(Result.flatMap(s -> Result.flatMap(d -> FileWrite.moveWith(src = s, d, opts), c(dst)), c(src#src)))
            def delete(file, k)             = k(Result.flatMap(p -> FileWrite.delete(p), c(file)))
            def mkDir(d, k)                 = k(Result.flatMap(p -> FileWrite.mkDir(p), c(d)))
            def mkDirs(d, k)                = k(Result.flatMap(p -> FileWrite.mkDirs(p), c(d)))
            def mkTempDir(_prefix, k)       = k(Err(IoError(ErrorKind.PermissionDenied, "mkTempDir is not permitted with an allow list")))
        }

    ///
    /// Middleware that intercepts the `FileWrite` effect, validating that all file paths
    /// do not resolve within any of the given `deniedDirs`. Rejects paths inside a
    /// denied directory with a `PermissionDenied` error.
    ///
    /// The `mkTempDir` operation is allowed since deny lists are default-allow.
    ///
    pub def withDenyList(deniedDirs: Nel[String], f: Unit -> a \ ef): a \ (ef - FileWrite) + FileWrite =
        let c = p -> Fs.FsLayer.denyList(deniedDirs, p);
        run { f() } with handler FileWrite {
            def write(data, file, k)        = k(Result.flatMap(p -> FileWrite.write(data, p), c(file)))
            def writeLines(data, file, k)   = k(Result.flatMap(p -> FileWrite.writeLines(data, p), c(file)))
            def writeBytes(data, file, k)   = k(Result.flatMap(p -> FileWrite.writeBytes(data, p), c(file)))
            def append(data, file, k)       = k(Result.flatMap(p -> FileWrite.append(data, p), c(file)))
            def appendLines(data, file, k)  = k(Result.flatMap(p -> FileWrite.appendLines(data, p), c(file)))
            def appendBytes(data, file, k)  = k(Result.flatMap(p -> FileWrite.appendBytes(data, p), c(file)))
            def truncate(file, k)           = k(Result.flatMap(p -> FileWrite.truncate(p), c(file)))
            def copyWith(src, dst, opts, k) = k(Result.flatMap(s -> Result.flatMap(d -> FileWrite.copyWith(src = s, d, opts), c(dst)), c(src#src)))
            def moveWith(src, dst, opts, k) = k(Result.flatMap(s -> Result.flatMap(d -> FileWrite.moveWith(src = s, d, opts), c(dst)), c(src#src)))
            def delete(file, k)             = k(Result.flatMap(p -> FileWrite.delete(p), c(file)))
            def mkDir(d, k)                 = k(Result.flatMap(p -> FileWrite.mkDir(p), c(d)))
            def mkDirs(d, k)                = k(Result.flatMap(p -> FileWrite.mkDirs(p), c(d)))
            def mkTempDir(prefix, k)        = k(FileWrite.mkTempDir(prefix))
        }

    ///
    /// Middleware that intercepts the `FileWrite` effect, validating that all file paths
    /// match at least one of the given glob `patterns`. Rejects paths that do not match
    /// with a `PermissionDenied` error.
    ///
    /// The `mkTempDir` operation is blocked since the temp directory location cannot be verified.
    ///
    pub def withAllowGlob(patterns: Nel[String], f: Unit -> a \ ef): a \ (ef - FileWrite) + FileWrite =
        use IoError.IoError;
        use IoError.ErrorKind;
        let c = p -> Fs.FsLayer.allowGlob(patterns, p);
        run { f() } with handler FileWrite {
            def write(data, file, k)        = k(Result.flatMap(p -> FileWrite.write(data, p), c(file)))
            def writeLines(data, file, k)   = k(Result.flatMap(p -> FileWrite.writeLines(data, p), c(file)))
            def writeBytes(data, file, k)   = k(Result.flatMap(p -> FileWrite.writeBytes(data, p), c(file)))
            def append(data, file, k)       = k(Result.flatMap(p -> FileWrite.append(data, p), c(file)))
            def appendLines(data, file, k)  = k(Result.flatMap(p -> FileWrite.appendLines(data, p), c(file)))
            def appendBytes(data, file, k)  = k(Result.flatMap(p -> FileWrite.appendBytes(data, p), c(file)))
            def truncate(file, k)           = k(Result.flatMap(p -> FileWrite.truncate(p), c(file)))
            def copyWith(src, dst, opts, k) = k(Result.flatMap(s -> Result.flatMap(d -> FileWrite.copyWith(src = s, d, opts), c(dst)), c(src#src)))
            def moveWith(src, dst, opts, k) = k(Result.flatMap(s -> Result.flatMap(d -> FileWrite.moveWith(src = s, d, opts), c(dst)), c(src#src)))
            def delete(file, k)             = k(Result.flatMap(p -> FileWrite.delete(p), c(file)))
            def mkDir(d, k)                 = k(Result.flatMap(p -> FileWrite.mkDir(p), c(d)))
            def mkDirs(d, k)                = k(Result.flatMap(p -> FileWrite.mkDirs(p), c(d)))
            def mkTempDir(_prefix, k)       = k(Err(IoError(ErrorKind.PermissionDenied, "mkTempDir is not permitted with an allow glob")))
        }

    ///
    /// Middleware that intercepts the `FileWrite` effect, validating that all file paths
    /// do not match any of the given glob `patterns`. Rejects paths that match a denied
    /// pattern with a `PermissionDenied` error.
    ///
    /// The `mkTempDir` operation is allowed since deny globs are default-allow.
    ///
    pub def withDenyGlob(patterns: Nel[String], f: Unit -> a \ ef): a \ (ef - FileWrite) + FileWrite =
        let c = p -> Fs.FsLayer.denyGlob(patterns, p);
        run { f() } with handler FileWrite {
            def write(data, file, k)        = k(Result.flatMap(p -> FileWrite.write(data, p), c(file)))
            def writeLines(data, file, k)   = k(Result.flatMap(p -> FileWrite.writeLines(data, p), c(file)))
            def writeBytes(data, file, k)   = k(Result.flatMap(p -> FileWrite.writeBytes(data, p), c(file)))
            def append(data, file, k)       = k(Result.flatMap(p -> FileWrite.append(data, p), c(file)))
            def appendLines(data, file, k)  = k(Result.flatMap(p -> FileWrite.appendLines(data, p), c(file)))
            def appendBytes(data, file, k)  = k(Result.flatMap(p -> FileWrite.appendBytes(data, p), c(file)))
            def truncate(file, k)           = k(Result.flatMap(p -> FileWrite.truncate(p), c(file)))
            def copyWith(src, dst, opts, k) = k(Result.flatMap(s -> Result.flatMap(d -> FileWrite.copyWith(src = s, d, opts), c(dst)), c(src#src)))
            def moveWith(src, dst, opts, k) = k(Result.flatMap(s -> Result.flatMap(d -> FileWrite.moveWith(src = s, d, opts), c(dst)), c(src#src)))
            def delete(file, k)             = k(Result.flatMap(p -> FileWrite.delete(p), c(file)))
            def mkDir(d, k)                 = k(Result.flatMap(p -> FileWrite.mkDir(p), c(d)))
            def mkDirs(d, k)                = k(Result.flatMap(p -> FileWrite.mkDirs(p), c(d)))
            def mkTempDir(prefix, k)        = k(FileWrite.mkTempDir(prefix))
        }

    ///
    /// Middleware that intercepts the `FileWrite` effect, resolving all file paths
    /// to their real filesystem targets by following symlinks.
    ///
    /// Action ops (`write`, `append`, `copy`, etc.): resolve path with fallback to original.
    /// `mkTempDir`: passed through unchanged (no path to resolve).
    ///
    pub def withFollowLinks(f: Unit -> a \ ef): a \ (ef - FileWrite) + FileWrite =
        let resolve = p -> Fs.FsLayer.resolveRealPathOrSelf(p);
        run { f() } with handler FileWrite {
            def write(data, file, k)        = k(FileWrite.write(data, resolve(file)))
            def writeLines(data, file, k)   = k(FileWrite.writeLines(data, resolve(file)))
            def writeBytes(data, file, k)   = k(FileWrite.writeBytes(data, resolve(file)))
            def append(data, file, k)       = k(FileWrite.append(data, resolve(file)))
            def appendLines(data, file, k)  = k(FileWrite.appendLines(data, resolve(file)))
            def appendBytes(data, file, k)  = k(FileWrite.appendBytes(data, resolve(file)))
            def truncate(file, k)           = k(FileWrite.truncate(resolve(file)))
            def copyWith(src, dst, opts, k) = k(FileWrite.copyWith(src = resolve(src#src), resolve(dst), opts))
            def moveWith(src, dst, opts, k) = k(FileWrite.moveWith(src = resolve(src#src), resolve(dst), opts))
            def delete(file, k)             = k(FileWrite.delete(resolve(file)))
            def mkDir(d, k)                 = k(FileWrite.mkDir(resolve(d)))
            def mkDirs(d, k)                = k(FileWrite.mkDirs(resolve(d)))
            def mkTempDir(prefix, k)        = k(FileWrite.mkTempDir(prefix))
        }

    ///
    /// Middleware that intercepts the `FileWrite` effect, logging each write
    /// operation via the `Logger` effect at `Debug` level without performing
    /// the actual filesystem modification. All write operations return `Ok(())`;
    /// `mkTempDir` returns `Ok("<dry-run>")`.
    ///
    pub def withDryRun(f: Unit -> a \ ef): a \ (ef - FileWrite) + Logger =
        use Logger.Severity;
        use RichString.{text, bold, cyan, yellow, gray};
        let tag = yellow("[dry-run]");
        run { f() } with handler FileWrite {
            def write(data, file, k) = {
                Logger.log(Severity.Debug, tag + text(" ") + bold("write") + text(" ") + cyan(file) + gray(" (${String.length(data#str)} chars)"));
                k(Ok(()))
            }
            def writeLines(data, file, k) = {
                Logger.log(Severity.Debug, tag + text(" ") + bold("writeLines") + text(" ") + cyan(file) + gray(" (${List.length(data#lines)} lines)"));
                k(Ok(()))
            }
            def writeBytes(data, file, k) = {
                Logger.log(Severity.Debug, tag + text(" ") + bold("writeBytes") + text(" ") + cyan(file) + gray(" (${Vector.length(data)} bytes)"));
                k(Ok(()))
            }
            def append(data, file, k) = {
                Logger.log(Severity.Debug, tag + text(" ") + bold("append") + text(" ") + cyan(file) + gray(" (${String.length(data#str)} chars)"));
                k(Ok(()))
            }
            def appendLines(data, file, k) = {
                Logger.log(Severity.Debug, tag + text(" ") + bold("appendLines") + text(" ") + cyan(file) + gray(" (${List.length(data#lines)} lines)"));
                k(Ok(()))
            }
            def appendBytes(data, file, k) = {
                Logger.log(Severity.Debug, tag + text(" ") + bold("appendBytes") + text(" ") + cyan(file) + gray(" (${Vector.length(data)} bytes)"));
                k(Ok(()))
            }
            def truncate(file, k) = {
                Logger.log(Severity.Debug, tag + text(" ") + bold("truncate") + text(" ") + cyan(file));
                k(Ok(()))
            }
            def copyWith(src, dst, _opts, k) = {
                Logger.log(Severity.Debug, tag + text(" ") + bold("copy") + text(" ") + cyan(src#src) + gray(" -> ") + cyan(dst));
                k(Ok(()))
            }
            def moveWith(src, dst, _opts, k) = {
                Logger.log(Severity.Debug, tag + text(" ") + bold("move") + text(" ") + cyan(src#src) + gray(" -> ") + cyan(dst));
                k(Ok(()))
            }
            def delete(file, k) = {
                Logger.log(Severity.Debug, tag + text(" ") + bold("delete") + text(" ") + cyan(file));
                k(Ok(()))
            }
            def mkDir(d, k) = {
                Logger.log(Severity.Debug, tag + text(" ") + bold("mkDir") + text(" ") + cyan(d));
                k(Ok(()))
            }
            def mkDirs(d, k) = {
                Logger.log(Severity.Debug, tag + text(" ") + bold("mkDirs") + text(" ") + cyan(d));
                k(Ok(()))
            }
            def mkTempDir(prefix, k) = {
                Logger.log(Severity.Debug, tag + text(" ") + bold("mkTempDir") + text(" ") + cyan(prefix));
                k(Ok("<dry-run>"))
            }
        }

    ///
    /// Middleware that intercepts the `FileWrite` effect, blocking all write
    /// operations with a `PermissionDenied` error. This is useful for
    /// sandboxing code that should only observe the filesystem.
    ///
    pub def withReadOnly(f: Unit -> a \ ef): a \ (ef - FileWrite) =
        use IoError.IoError;
        use IoError.ErrorKind;
        let denied = op -> Err(IoError(ErrorKind.PermissionDenied, "read-only: ${op} is not permitted"));
        run { f() } with handler FileWrite {
            def write(_data, _file, k)         = k(denied("write"))
            def writeLines(_data, _file, k)    = k(denied("writeLines"))
            def writeBytes(_data, _file, k)    = k(denied("writeBytes"))
            def append(_data, _file, k)        = k(denied("append"))
            def appendLines(_data, _file, k)   = k(denied("appendLines"))
            def appendBytes(_data, _file, k)   = k(denied("appendBytes"))
            def truncate(_file, k)             = k(denied("truncate"))
            def copyWith(_src, _dst, _opts, k) = k(denied("copy"))
            def moveWith(_src, _dst, _opts, k) = k(denied("move"))
            def delete(_file, k)               = k(denied("delete"))
            def mkDir(_d, k)                   = k(denied("mkDir"))
            def mkDirs(_d, k)                  = k(denied("mkDirs"))
            def mkTempDir(_prefix, k)          = k(Err(IoError(ErrorKind.PermissionDenied, "read-only: mkTempDir is not permitted")))
        }

    ///
    /// Middleware that intercepts the `FileWrite` effect, making `write`,
    /// `writeLines`, and `writeBytes` atomic. Data is first written to a
    /// temporary file in the same directory, then atomically moved into place.
    /// On failure the temporary file is cleaned up.
    ///
    /// All other operations (append, truncate, copy, move, delete, mkdir) are
    /// passed through unchanged.
    ///
    pub def withAtomicWrite(f: Unit -> a \ ef): a \ (ef - FileWrite) + FileWrite =
        let mv = (tmp, dst) -> FileWrite.moveWith(src = tmp, dst, Set#{Fs.MoveOption.AtomicMove, Fs.MoveOption.ReplaceExisting});
        let del = tmp -> FileWrite.delete(tmp);
        run { f() } with handler FileWrite {
            def write(data, file, k)        = k(Fs.FsLayer.atomicWrite(file, tmp -> FileWrite.write(data, tmp), mv, del))
            def writeLines(data, file, k)   = k(Fs.FsLayer.atomicWrite(file, tmp -> FileWrite.writeLines(data, tmp), mv, del))
            def writeBytes(data, file, k)   = k(Fs.FsLayer.atomicWrite(file, tmp -> FileWrite.writeBytes(data, tmp), mv, del))
            def append(data, file, k)       = k(FileWrite.append(data, file))
            def appendLines(data, file, k)  = k(FileWrite.appendLines(data, file))
            def appendBytes(data, file, k)  = k(FileWrite.appendBytes(data, file))
            def truncate(file, k)           = k(FileWrite.truncate(file))
            def copyWith(src, dst, opts, k) = k(FileWrite.copyWith(src, dst, opts))
            def moveWith(src, dst, opts, k) = k(FileWrite.moveWith(src, dst, opts))
            def delete(file, k)             = k(FileWrite.delete(file))
            def mkDir(d, k)                 = k(FileWrite.mkDir(d))
            def mkDirs(d, k)                = k(FileWrite.mkDirs(d))
            def mkTempDir(prefix, k)        = k(FileWrite.mkTempDir(prefix))
        }

    ///
    /// Middleware that intercepts the `FileWrite` effect, creating a backup
    /// of existing files before destructive operations. Before `write`,
    /// `writeLines`, `writeBytes`, `truncate`, and `delete`, the existing
    /// file is copied to `file + suffix` (e.g., `"data.txt.bak"` when
    /// suffix is `".bak"`).
    ///
    /// For `copyWith` and `moveWith`, the destination file is backed up.
    ///
    /// If the file does not exist, no backup is created and the operation
    /// proceeds normally. If the backup fails for any other reason, the
    /// original operation is aborted and the backup error is returned.
    ///
    /// If a backup file already exists, it is overwritten. Only the most
    /// recent previous version of each file is preserved.
    ///
    /// Append, mkdir, and mkTempDir operations are passed through unchanged.
    ///
    pub def withBackup(suffix: String, f: Unit -> a \ ef): a \ (ef - FileWrite) + FileWrite =
        let doCopy = (src, dst) -> FileWrite.copyWith(src = src, dst, Set#{Fs.CopyOption.ReplaceExisting});
        run { f() } with handler FileWrite {
            def write(data, file, k)        = k(Fs.FsLayer.backup(file, suffix, doCopy, () -> FileWrite.write(data, file)))
            def writeLines(data, file, k)   = k(Fs.FsLayer.backup(file, suffix, doCopy, () -> FileWrite.writeLines(data, file)))
            def writeBytes(data, file, k)   = k(Fs.FsLayer.backup(file, suffix, doCopy, () -> FileWrite.writeBytes(data, file)))
            def truncate(file, k)           = k(Fs.FsLayer.backup(file, suffix, doCopy, () -> FileWrite.truncate(file)))
            def delete(file, k)             = k(Fs.FsLayer.backup(file, suffix, doCopy, () -> FileWrite.delete(file)))
            def copyWith(src, dst, opts, k) = k(Fs.FsLayer.backup(dst, suffix, doCopy, () -> FileWrite.copyWith(src, dst, opts)))
            def moveWith(src, dst, opts, k) = k(Fs.FsLayer.backup(dst, suffix, doCopy, () -> FileWrite.moveWith(src, dst, opts)))
            def append(data, file, k)       = k(FileWrite.append(data, file))
            def appendLines(data, file, k)  = k(FileWrite.appendLines(data, file))
            def appendBytes(data, file, k)  = k(FileWrite.appendBytes(data, file))
            def mkDir(d, k)                 = k(FileWrite.mkDir(d))
            def mkDirs(d, k)                = k(FileWrite.mkDirs(d))
            def mkTempDir(prefix, k)        = k(FileWrite.mkTempDir(prefix))
        }

    ///
    /// Middleware that intercepts the `FileWrite` effect, automatically creating
    /// parent directories before write, append, copy, and move operations.
    ///
    /// Before `write`, `writeLines`, `writeBytes`, `append`, `appendLines`,
    /// `appendBytes`, `copyWith`, and `moveWith`, the parent directory of the
    /// target file is created via `mkDirs`. If the parent directory already
    /// exists, this is a no-op.
    ///
    /// If parent directory creation fails, the original operation is aborted
    /// and the error is returned.
    ///
    /// `truncate`, `delete`, `mkDir`, `mkDirs`, and `mkTempDir` are passed
    /// through unchanged.
    ///
    pub def withMkParentDirs(f: Unit -> a \ ef): a \ (ef - FileWrite) + FileWrite =
        let mk = d -> FileWrite.mkDirs(d);
        run { f() } with handler FileWrite {
            def write(data, file, k)        = k(Fs.FsLayer.ensureParentDirs(file, mk, () -> FileWrite.write(data, file)))
            def writeLines(data, file, k)   = k(Fs.FsLayer.ensureParentDirs(file, mk, () -> FileWrite.writeLines(data, file)))
            def writeBytes(data, file, k)   = k(Fs.FsLayer.ensureParentDirs(file, mk, () -> FileWrite.writeBytes(data, file)))
            def append(data, file, k)       = k(Fs.FsLayer.ensureParentDirs(file, mk, () -> FileWrite.append(data, file)))
            def appendLines(data, file, k)  = k(Fs.FsLayer.ensureParentDirs(file, mk, () -> FileWrite.appendLines(data, file)))
            def appendBytes(data, file, k)  = k(Fs.FsLayer.ensureParentDirs(file, mk, () -> FileWrite.appendBytes(data, file)))
            def copyWith(src, dst, opts, k) = k(Fs.FsLayer.ensureParentDirs(dst, mk, () -> FileWrite.copyWith(src, dst, opts)))
            def moveWith(src, dst, opts, k) = k(Fs.FsLayer.ensureParentDirs(dst, mk, () -> FileWrite.moveWith(src, dst, opts)))
            def truncate(file, k)           = k(FileWrite.truncate(file))
            def delete(file, k)             = k(FileWrite.delete(file))
            def mkDir(d, k)                 = k(FileWrite.mkDir(d))
            def mkDirs(d, k)                = k(FileWrite.mkDirs(d))
            def mkTempDir(prefix, k)        = k(FileWrite.mkTempDir(prefix))
        }

    ///
    /// Middleware that intercepts the `FileWrite` effect, automatically rotating
    /// files when they reach a size threshold before append operations.
    ///
    /// Before `append`, `appendLines`, and `appendBytes`, the target file's size
    /// is checked via `FileStat.size`. If it meets or exceeds `maxSize`, existing
    /// rotated files are shifted (`file.1` → `file.2`, etc.), the oldest
    /// (`file.{maxFiles}`) is deleted, and the current file is moved to `file.1`.
    /// The append then writes to the now-absent original path.
    ///
    /// All other operations are passed through unchanged.
    ///
    pub def withSizeRotation(maxSize: Size, maxFiles: Int32, f: Unit -> a \ ef): a \ (ef - FileWrite) + { FileWrite, FileStat } =
        let doSize = file -> FileStat.size(file);
        let doMove = (src, dst) -> FileWrite.moveWith(src = src, dst, Set#{Fs.MoveOption.ReplaceExisting});
        let doDelete = file -> FileWrite.delete(file);
        run { f() } with handler FileWrite {
            // Append ops — rotate if needed before appending
            def append(data, file, k)       = k(Fs.FsLayer.rotateIfNeeded(file, maxSize, maxFiles, doSize, doMove, doDelete, () -> FileWrite.append(data, file)))
            def appendLines(data, file, k)  = k(Fs.FsLayer.rotateIfNeeded(file, maxSize, maxFiles, doSize, doMove, doDelete, () -> FileWrite.appendLines(data, file)))
            def appendBytes(data, file, k)  = k(Fs.FsLayer.rotateIfNeeded(file, maxSize, maxFiles, doSize, doMove, doDelete, () -> FileWrite.appendBytes(data, file)))
            // Other write ops — pass through
            def write(data, file, k)        = k(FileWrite.write(data, file))
            def writeLines(data, file, k)   = k(FileWrite.writeLines(data, file))
            def writeBytes(data, file, k)   = k(FileWrite.writeBytes(data, file))
            def truncate(file, k)           = k(FileWrite.truncate(file))
            def copyWith(src, dst, opts, k) = k(FileWrite.copyWith(src, dst, opts))
            def moveWith(src, dst, opts, k) = k(FileWrite.moveWith(src, dst, opts))
            def delete(file, k)             = k(FileWrite.delete(file))
            def mkDir(d, k)                 = k(FileWrite.mkDir(d))
            def mkDirs(d, k)                = k(FileWrite.mkDirs(d))
            def mkTempDir(prefix, k)        = k(FileWrite.mkTempDir(prefix))
        }

    ///
    /// Middleware that intercepts the `FileWrite` effect, rejecting write and
    /// append operations where the payload exceeds `maxBytes`. For `copyWith`,
    /// the source file size is checked via `FileStat.size`.
    ///
    /// String-based operations estimate byte count as `charCount * 4` (the
    /// maximum bytes per character in UTF-8). Use `writeBytes`/`appendBytes`
    /// for exact enforcement.
    ///
    /// Operations that don't transfer data (`truncate`, `moveWith`, `delete`,
    /// `mkDir`, `mkDirs`, `mkTempDir`) are passed through unchanged.
    ///
    pub def withTransferLimit(maxSize: Size, f: Unit -> a \ ef): a \ (ef - FileWrite) + { FileWrite, FileStat } =
        let check = sz -> Fs.FsLayer.checkTransferLimit(maxSize, sz);
        let estStr = s -> Fs.FsLayer.estimateStringBytes(s);
        run { f() } with handler FileWrite {
            def write(data, file, k)        = k(Result.flatMap(_ -> FileWrite.write(data, file), check(estStr(data#str))))
            def writeLines(data, file, k)   = k(Result.flatMap(_ -> FileWrite.writeLines(data, file), check(Fs.FsLayer.estimateStringListBytes(data#lines))))
            def writeBytes(data, file, k)   = k(Result.flatMap(_ -> FileWrite.writeBytes(data, file), check(bytes(Int32.toInt64(Vector.length(data))))))
            def append(data, file, k)       = k(Result.flatMap(_ -> FileWrite.append(data, file), check(estStr(data#str))))
            def appendLines(data, file, k)  = k(Result.flatMap(_ -> FileWrite.appendLines(data, file), check(Fs.FsLayer.estimateStringListBytes(data#lines))))
            def appendBytes(data, file, k)  = k(Result.flatMap(_ -> FileWrite.appendBytes(data, file), check(bytes(Int32.toInt64(Vector.length(data))))))
            def truncate(file, k)           = k(FileWrite.truncate(file))
            def copyWith(src, dst, opts, k) = k(Result.flatMap(sz -> Result.flatMap(_ -> FileWrite.copyWith(src, dst, opts), check(sz)), FileStat.size(src#src)))
            def moveWith(src, dst, opts, k) = k(FileWrite.moveWith(src, dst, opts))
            def delete(file, k)             = k(FileWrite.delete(file))
            def mkDir(d, k)                 = k(FileWrite.mkDir(d))
            def mkDirs(d, k)                = k(FileWrite.mkDirs(d))
            def mkTempDir(prefix, k)        = k(FileWrite.mkTempDir(prefix))
        }

    ///
    /// Middleware that intercepts the `FileWrite` effect, tracking file modification
    /// times across writes and checking for external modifications before each write.
    ///
    /// Before `write`, `writeLines`, `writeBytes`, `append`, `appendLines`,
    /// `appendBytes`, and `truncate`, the file's current modification time is
    /// compared against the last recorded snapshot. If they differ, a `Conflict`
    /// error is returned. After a successful write, the modification time is
    /// re-snapshotted for subsequent checks.
    ///
    /// For `copyWith` and `moveWith`, the conflict check is applied to the
    /// destination path.
    ///
    /// After `delete`, the snapshot for that file is removed.
    ///
    /// `mkDir`, `mkDirs`, and `mkTempDir` are passed through unchanged.
    ///
    /// Note: Unlike the `FileSystem` version, this middleware does not snapshot
    /// on read operations. Conflict detection begins after the first write to
    /// each file, catching write-write conflicts from external processes.
    ///
    pub def withConflictCheck(f: Unit -> a \ ef): a \ (ef - FileWrite) + { FileWrite, FileStat } =
        region rc {
            let snapshots: MutMap[String, Int64, rc] = MutMap.empty(rc);
            let getModTime = file -> FileStat.modificationTime(file);
            let snap = file -> Fs.FsLayer.snapshotModTime(file, snapshots, getModTime);
            let check = file -> Fs.FsLayer.conflictCheck(file, snapshots, getModTime);
            let checkAndResnap = (file, doWrite) ->
                forM (
                    _ <- check(file);
                    v <- doWrite()
                ) yield { snap(file); v };
            run { f() } with handler FileWrite {
                // ── Write ops — conflict check before, re-snapshot after ──
                def write(data, file, k)        = k(checkAndResnap(file, () -> FileWrite.write(data, file)))
                def writeLines(data, file, k)   = k(checkAndResnap(file, () -> FileWrite.writeLines(data, file)))
                def writeBytes(data, file, k)   = k(checkAndResnap(file, () -> FileWrite.writeBytes(data, file)))
                def append(data, file, k)       = k(checkAndResnap(file, () -> FileWrite.append(data, file)))
                def appendLines(data, file, k)  = k(checkAndResnap(file, () -> FileWrite.appendLines(data, file)))
                def appendBytes(data, file, k)  = k(checkAndResnap(file, () -> FileWrite.appendBytes(data, file)))
                def truncate(file, k)           = k(checkAndResnap(file, () -> FileWrite.truncate(file)))
                def delete(file, k) = {
                    match check(file) {
                        case Err(e) => k(Err(e))
                        case Ok(_) =>
                            let result = FileWrite.delete(file);
                            match result {
                                case Ok(_) => MutMap.remove(file, snapshots)
                                case _     => ()
                            };
                            k(result)
                    }
                }
                // ── Copy/move — conflict check on destination ──
                def copyWith(src, dst, opts, k) = k(checkAndResnap(dst, () -> FileWrite.copyWith(src, dst, opts)))
                def moveWith(src, dst, opts, k) = k(checkAndResnap(dst, () -> FileWrite.moveWith(src, dst, opts)))
                // ── Directory ops — pass through ──
                def mkDir(d, k)                 = k(FileWrite.mkDir(d))
                def mkDirs(d, k)                = k(FileWrite.mkDirs(d))
                def mkTempDir(prefix, k)        = k(FileWrite.mkTempDir(prefix))
            }
        }

    ///
    /// Middleware that intercepts the `FileWrite` effect, computing checksums
    /// after write operations and storing them as sidecar files.
    ///
    /// `hash` is a pure function from file bytes to digest bytes.
    /// `sidecar` is the file suffix (e.g., `".sha256"`).
    ///
    /// After `write`, `writeLines`, `writeBytes`, `append`, `appendLines`,
    /// `appendBytes`, `truncate`, and `copyWith`, the file is re-read and
    /// its checksum sidecar is updated.
    ///
    /// After `moveWith`, the source's sidecar is moved to the destination path.
    /// After `delete`, the sidecar file is also removed.
    ///
    /// `mkDir`, `mkDirs`, and `mkTempDir` are passed through unchanged.
    ///
    pub def withChecksum(
        hash: Vector[Int8] -> Vector[Int8],
        sidecar: String,
        f: Unit -> a \ ef
    ): a \ (ef - FileWrite) + { FileWrite, FileRead } =
        let computeHex = bytes -> Util.Codec.encodeHex(hash(bytes));
        let updateSidecar = file -> Fs.FsLayer.updateSidecar(file, sidecar, computeHex, f1 -> FileRead.readBytes(f1), (str, f1) -> FileWrite.write(str = str, f1));
        let afterWrite = (file, doWrite) -> Fs.FsLayer.checksumAfterWrite(file, doWrite, updateSidecar);
        let doMoveSidecar = (s, d) -> FileWrite.moveWith(src = s, d, Set#{Fs.MoveOption.ReplaceExisting});
        let doDeleteSidecar = file -> FileWrite.delete(file);
        run { f() } with handler FileWrite {
            def write(data, file, k)        = k(afterWrite(file, () -> FileWrite.write(data, file)))
            def writeLines(data, file, k)   = k(afterWrite(file, () -> FileWrite.writeLines(data, file)))
            def writeBytes(data, file, k)   = k(afterWrite(file, () -> FileWrite.writeBytes(data, file)))
            def append(data, file, k)       = k(afterWrite(file, () -> FileWrite.append(data, file)))
            def appendLines(data, file, k)  = k(afterWrite(file, () -> FileWrite.appendLines(data, file)))
            def appendBytes(data, file, k)  = k(afterWrite(file, () -> FileWrite.appendBytes(data, file)))
            def truncate(file, k)           = k(afterWrite(file, () -> FileWrite.truncate(file)))
            def copyWith(src, dst, opts, k) = k(afterWrite(dst, () -> FileWrite.copyWith(src, dst, opts)))
            def moveWith(src, dst, opts, k) = k(Fs.FsLayer.checksumAfterMove(src#src, dst, sidecar, FileWrite.moveWith(src, dst, opts), doMoveSidecar))
            def delete(file, k)             = k(Fs.FsLayer.checksumAfterDelete(file, sidecar, FileWrite.delete(file), doDeleteSidecar))
            def mkDir(d, k)                 = k(FileWrite.mkDir(d))
            def mkDirs(d, k)                = k(FileWrite.mkDirs(d))
            def mkTempDir(prefix, k)        = k(FileWrite.mkTempDir(prefix))
        }

}