Skip to content

Add Path.copy! and Path.copy_dir! #486

Description

@lukewilliamboswell

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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions