Motivation
Repository tooling for a Roc package (roc-time), written as Roc scripts on basic-cli, needs to copy files and trees: stage each example into a scratch directory so its package URL can be rewritten to point at a bundled artifact without dirtying the working tree, and copy generated docs into a versioned output directory.
What exists today
Path has creation, deletion, linking and moving, but no copy:
hard_link! : Path, Path => Try({}, [PathErr(IOErr, Path), ..])
rename! : Path, Path => Try({}, [PathErr(IOErr, Path), ..])
create_dir!: Path => Try({}, [PathErr(IOErr, Path), ..])
create_all!: Path => Try({}, [PathErr(IOErr, Path), ..])
delete! : Path => Try({}, [PathErr(IOErr, Path), ..])
delete_all!: Path => Try({}, [PathErr(IOErr, Path), ..])
list! : Path => Try(List(Path), [PathErr(IOErr, Path), ..])
read_bytes!: Path => Try(List(U8), [PathErr(IOErr, Path), ..])
write_bytes!: Path, List(U8) => Try({}, [PathErr(IOErr, Path), ..])
Host.roc likewise has file_read_bytes! / file_write_bytes! and no copy effect.
So a copy today is read_bytes! + write_bytes!, which:
- loses the file mode (an executable loses its
+x bit — relevant since Path.is_executable! and Cmd.check_available! depend on it),
- buffers the whole file in memory,
- gives up any platform copy fast path (
copy_file_range, fclonefileat on APFS, CopyFileW),
- and for a directory tree means hand-writing recursion over
list! in every script.
rename! is not a substitute: it moves rather than copies, and fails across filesystems.
Proposed API sketch
## Copy the file at `from` to `to`, replacing `to` if it already exists.
## The permission bits of the source are preserved. `from` must be a file,
## not a directory; use [copy_dir!] for directories.
##
## ```roc
## Path.utf8("dist/package.tar.zst").copy!(Path.utf8("build/package.tar.zst"))?
## ```
copy! : Path, Path => Try({}, [PathErr(IOErr, Path), ..])
## Recursively copy the directory at `from` into `to`, creating `to` and any
## missing parents.
copy_dir! : Path, Path => Try({}, [PathErr(IOErr, Path), ..])
Open questions for the API: should copy_dir! follow symlinks or recreate them, and should it error or merge when the destination exists? Python's shutil.copytree takes symlinks= and dirs_exist_ok= flags for exactly these; picking one documented behaviour is probably better than flags for basic-cli's style.
Host support
copy! is std::fs::copy in the Rust host — one new hosted effect over the existing NativePath representation. std::fs::copy already preserves permission bits and uses platform fast paths.
copy_dir! has no std equivalent, so it's either a small recursive walk in the host (probably better: fewer ABI crossings, and it can preserve modes) or a Roc-level helper over list! + copy! + create_all!. I'd suggest the host, matching how dir_delete_all! is already a single hosted effect rather than Roc-level recursion.
Cross-platform notes
- Permission semantics differ:
std::fs::copy copies the Unix mode bits; on Windows it copies file attributes. Worth documenting rather than promising uniformity.
- Symlinks, hard links, and special files (sockets, FIFOs — cf. #441) need a stated policy in
copy_dir!.
- Copying does not preserve timestamps in
std::fs::copy; if that matters it should be documented as not preserved.
Prior art
- Rust:
std::fs::copy (no recursive copy in std; fs_extra/walkdir in the ecosystem)
- Go: no stdlib copy;
os.CopyFS since 1.23
- Node:
fs.copyFile, fs.cp(src, dest, { recursive: true })
- Python:
shutil.copy2, shutil.copytree
Old (pre-Zig-compiler) basic-cli had neither in Path/File/Dir, so this is not a regression.
Alternatives considered
Cmd.exec!("cp", ["-r", ...]): what my script fell back to, but it shells out, is not portable to Windows, and needs cp on PATH.
hard_link!: wrong semantics (shared inode, same-filesystem only).
Motivation
Repository tooling for a Roc package (
roc-time), written as Roc scripts on basic-cli, needs to copy files and trees: stage each example into a scratch directory so itspackageURL can be rewritten to point at a bundled artifact without dirtying the working tree, and copy generated docs into a versioned output directory.What exists today
Pathhas creation, deletion, linking and moving, but no copy:Host.roclikewise hasfile_read_bytes!/file_write_bytes!and no copy effect.So a copy today is
read_bytes!+write_bytes!, which:+xbit — relevant sincePath.is_executable!andCmd.check_available!depend on it),copy_file_range,fclonefileaton APFS,CopyFileW),list!in every script.rename!is not a substitute: it moves rather than copies, and fails across filesystems.Proposed API sketch
Open questions for the API: should
copy_dir!follow symlinks or recreate them, and should it error or merge when the destination exists? Python'sshutil.copytreetakessymlinks=anddirs_exist_ok=flags for exactly these; picking one documented behaviour is probably better than flags for basic-cli's style.Host support
copy!isstd::fs::copyin the Rust host — one new hosted effect over the existingNativePathrepresentation.std::fs::copyalready preserves permission bits and uses platform fast paths.copy_dir!has nostdequivalent, so it's either a small recursive walk in the host (probably better: fewer ABI crossings, and it can preserve modes) or a Roc-level helper overlist!+copy!+create_all!. I'd suggest the host, matching howdir_delete_all!is already a single hosted effect rather than Roc-level recursion.Cross-platform notes
std::fs::copycopies the Unix mode bits; on Windows it copies file attributes. Worth documenting rather than promising uniformity.copy_dir!.std::fs::copy; if that matters it should be documented as not preserved.Prior art
std::fs::copy(no recursive copy instd;fs_extra/walkdirin the ecosystem)os.CopyFSsince 1.23fs.copyFile,fs.cp(src, dest, { recursive: true })shutil.copy2,shutil.copytreeOld (pre-Zig-compiler) basic-cli had neither in
Path/File/Dir, so this is not a regression.Alternatives considered
Cmd.exec!("cp", ["-r", ...]): what my script fell back to, but it shells out, is not portable to Windows, and needscpon PATH.hard_link!: wrong semantics (shared inode, same-filesystem only).