diff --git a/CHANGELOG.md b/CHANGELOG.md index 4399abe..f01e796 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added support for running directories with an `init.luau` or `init.lua` file in them in the CLI. +- Added `fs.copy` to recursively copy files and directories. + + Example usage: + + ```lua + local fs = require("@lune/fs") + + fs.writeDir("myCoolDir") + fs.writeFile("myCoolDir/myAwesomeFile.json", "{}") + + fs.copy("myCoolDir", "myCoolDir2") + + assert(fs.isDir("myCoolDir2")) + assert(fs.isFile("myCoolDir2/myAwesomeFile.json")) + assert(fs.readFile("myCoolDir2/myAwesomeFile.json") == "{}") + ``` + - Added `fs.metadata` to get metadata about files and directories. Example usage: @@ -30,6 +46,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 print(meta.permissions) --> { readOnly: false } ``` +- Added support for running directories with an `init.luau` or `init.lua` file in them in the CLI. + ### Changed - Update to Luau version `0.583` diff --git a/src/lune/builtins/fs.rs b/src/lune/builtins/fs.rs index 92c3c28..8c4a081 100644 --- a/src/lune/builtins/fs.rs +++ b/src/lune/builtins/fs.rs @@ -5,7 +5,7 @@ use mlua::prelude::*; use tokio::fs; use crate::lune::lua::{ - fs::{FsMetadata, FsWriteOptions}, + fs::{copy, FsMetadata, FsWriteOptions}, table::TableBuilder, }; @@ -21,6 +21,7 @@ pub fn create(lua: &'static Lua) -> LuaResult { .with_async_function("isFile", fs_is_file)? .with_async_function("isDir", fs_is_dir)? .with_async_function("move", fs_move)? + .with_async_function("copy", fs_copy)? .build_readonly() } @@ -117,7 +118,7 @@ async fn fs_move( let path_to = PathBuf::from(to); if !options.overwrite && path_to.exists() { return Err(LuaError::RuntimeError(format!( - "A file or directory alreadys exists at the path '{}'", + "A file or directory already exists at the path '{}'", path_to.display() ))); } @@ -126,3 +127,10 @@ async fn fs_move( .map_err(LuaError::external)?; Ok(()) } + +async fn fs_copy( + _: &'static Lua, + (from, to, options): (String, String, FsWriteOptions), +) -> LuaResult<()> { + copy(from, to, options).await +} diff --git a/src/lune/lua/fs/copy.rs b/src/lune/lua/fs/copy.rs new file mode 100644 index 0000000..00f37c3 --- /dev/null +++ b/src/lune/lua/fs/copy.rs @@ -0,0 +1,155 @@ +use std::collections::VecDeque; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use mlua::prelude::*; +use tokio::fs; + +use super::FsWriteOptions; + +pub struct CopyContents { + // Vec<(relative depth, path)> + pub dirs: Vec<(usize, PathBuf)>, + pub files: Vec<(usize, PathBuf)>, +} + +async fn get_contents_at(root: PathBuf, _options: FsWriteOptions) -> LuaResult { + let mut dirs = Vec::new(); + let mut files = Vec::new(); + + let mut queue = VecDeque::new(); + + let normalized_root = fs::canonicalize(&root).await.map_err(|e| { + LuaError::RuntimeError(format!("Failed to canonicalize root directory path\n{e}")) + })?; + + // Push initial children of the root path into the queue + let mut entries = fs::read_dir(&normalized_root).await?; + while let Some(entry) = entries.next_entry().await? { + queue.push_back((1, entry.path())); + } + + // Go through the current queue, pushing to it + // when we find any new descendant directories + // FUTURE: Try to do async reading here concurrently to speed it up a bit + while let Some((current_depth, current_path)) = queue.pop_front() { + let meta = fs::metadata(¤t_path).await?; + if meta.is_symlink() { + return Err(LuaError::RuntimeError(format!( + "Symlinks are not yet supported, encountered at path '{}'", + current_path.display() + ))); + } else if meta.is_dir() { + // FUTURE: Add an option in FsWriteOptions for max depth and limit it here + let mut entries = fs::read_dir(¤t_path).await?; + while let Some(entry) = entries.next_entry().await? { + queue.push_back((current_depth + 1, entry.path())); + } + dirs.push((current_depth, current_path)); + } else { + files.push((current_depth, current_path)); + } + } + + // Ensure that all directory and file paths are relative to the root path + // SAFETY: Since we only ever push dirs and files relative to the root, unwrap is safe + for (_, dir) in dirs.iter_mut() { + *dir = dir.strip_prefix(&normalized_root).unwrap().to_path_buf() + } + for (_, file) in files.iter_mut() { + *file = file.strip_prefix(&normalized_root).unwrap().to_path_buf() + } + + // FUTURE: Deduplicate paths such that these directories: + // - foo/ + // - foo/bar/ + // - foo/bar/baz/ + // turn into a single foo/bar/baz/ and let create_dir_all do the heavy lifting + + Ok(CopyContents { dirs, files }) +} + +async fn ensure_no_dir_exists(path: impl AsRef) -> LuaResult<()> { + let path = path.as_ref(); + match fs::metadata(&path).await { + Ok(meta) if meta.is_dir() => Err(LuaError::RuntimeError(format!( + "A directory already exists at the path '{}'", + path.display() + ))), + _ => Ok(()), + } +} + +async fn ensure_no_file_exists(path: impl AsRef) -> LuaResult<()> { + let path = path.as_ref(); + match fs::metadata(&path).await { + Ok(meta) if meta.is_file() => Err(LuaError::RuntimeError(format!( + "A file already exists at the path '{}'", + path.display() + ))), + _ => Ok(()), + } +} + +pub async fn copy( + source: impl AsRef, + target: impl AsRef, + options: FsWriteOptions, +) -> LuaResult<()> { + let source = source.as_ref(); + let target = target.as_ref(); + + // Check if we got a file or directory - we will handle them differently below + let (is_dir, is_file) = match fs::metadata(&source).await { + Ok(meta) => (meta.is_dir(), meta.is_file()), + Err(e) if e.kind() == ErrorKind::NotFound => { + return Err(LuaError::RuntimeError(format!( + "No file or directory exists at the path '{}'", + source.display() + ))) + } + Err(e) => return Err(e.into()), + }; + if !is_file && !is_dir { + return Err(LuaError::RuntimeError(format!( + "The given path '{}' is not a file or a directory", + source.display() + ))); + } + + // Perform copying: + // + // 1. If we are not allowed to overwrite, make sure nothing exists at the target path + // 2. If we are allowed to overwrite, remove any previous entry at the path + // 3. Write all directories first + // 4. Write all files + + if !options.overwrite { + if is_file { + ensure_no_file_exists(target).await?; + } else if is_dir { + ensure_no_dir_exists(target).await?; + } + } + + if is_file { + fs::copy(source, target).await?; + } else if is_dir { + let contents = get_contents_at(source.to_path_buf(), options).await?; + + if options.overwrite { + fs::remove_dir_all(target).await?; + } + + // FUTURE: Write dirs / files concurrently + // to potentially speed these operations up + for (_, dir) in &contents.dirs { + fs::create_dir_all(target.join(dir)).await?; + } + for (_, file) in &contents.files { + fs::copy(source.join(file), target.join(file)).await?; + } + } + + Ok(()) +} diff --git a/src/lune/lua/fs/mod.rs b/src/lune/lua/fs/mod.rs index be3d449..82378d7 100644 --- a/src/lune/lua/fs/mod.rs +++ b/src/lune/lua/fs/mod.rs @@ -1,5 +1,7 @@ +mod copy; mod metadata; mod options; +pub use copy::copy; pub use metadata::FsMetadata; pub use options::FsWriteOptions; diff --git a/src/lune/lua/fs/options.rs b/src/lune/lua/fs/options.rs index bd58715..d33c8f4 100644 --- a/src/lune/lua/fs/options.rs +++ b/src/lune/lua/fs/options.rs @@ -1,5 +1,6 @@ use mlua::prelude::*; +#[derive(Debug, Clone, Copy)] pub struct FsWriteOptions { pub(crate) overwrite: bool, } diff --git a/src/tests.rs b/src/tests.rs index 3dac041..f0b1407 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -39,6 +39,7 @@ macro_rules! create_tests { create_tests! { fs_files: "fs/files", + fs_copy: "fs/copy", fs_dirs: "fs/dirs", fs_metadata: "fs/metadata", fs_move: "fs/move", diff --git a/tests/fs/copy.luau b/tests/fs/copy.luau new file mode 100644 index 0000000..5095c0e --- /dev/null +++ b/tests/fs/copy.luau @@ -0,0 +1,72 @@ +local TEMP_DIR_PATH = "bin/" +local TEMP_ROOT_PATH = TEMP_DIR_PATH .. "fs_copy_test" +local TEMP_ROOT_PATH_2 = TEMP_DIR_PATH .. "fs_copy_test_2" + +local fs = require("@lune/fs") + +-- Generate test data & make sure our bin dir exists + +local binary = "" +for _ = 1, 1024 do + binary ..= string.char(math.random(1, 127)) +end + +fs.writeDir(TEMP_DIR_PATH) +if fs.isDir(TEMP_ROOT_PATH) then + fs.removeDir(TEMP_ROOT_PATH) +end +if fs.isDir(TEMP_ROOT_PATH_2) then + fs.removeDir(TEMP_ROOT_PATH_2) +end + +--[[ + Create a file structure like this: + + -> fs_copy_test + -- -> foo (dir) + -- -- -> bar (dir) + -- -- -- -> baz (file) + -- -- -> fizz (file) + -- -- -> buzz (file) + +]] + +fs.writeDir(TEMP_ROOT_PATH) +fs.writeDir(TEMP_ROOT_PATH .. "/foo") +fs.writeDir(TEMP_ROOT_PATH .. "/foo/bar") +fs.writeFile(TEMP_ROOT_PATH .. "/foo/bar/baz", binary) +fs.writeFile(TEMP_ROOT_PATH .. "/foo/fizz", binary) +fs.writeFile(TEMP_ROOT_PATH .. "/foo/buzz", binary) + +-- Copy the entire structure + +fs.copy(TEMP_ROOT_PATH, TEMP_ROOT_PATH_2) + +-- Verify the copied structure + +assert(fs.isDir(TEMP_ROOT_PATH_2), "Missing copied dir - root/") +assert(fs.isDir(TEMP_ROOT_PATH_2 .. "/foo"), "Missing copied dir - root/foo/") +assert(fs.isDir(TEMP_ROOT_PATH_2 .. "/foo/bar"), "Missing copied dir - root/foo/bar/") +assert(fs.isFile(TEMP_ROOT_PATH_2 .. "/foo/bar/baz"), "Missing copied file - root/foo/bar/baz") +assert(fs.isFile(TEMP_ROOT_PATH_2 .. "/foo/fizz"), "Missing copied file - root/foo/fizz") +assert(fs.isFile(TEMP_ROOT_PATH_2 .. "/foo/buzz"), "Missing copied file - root/foo/buzz") + +-- Make sure the copied files are correct + +assert( + fs.readFile(TEMP_ROOT_PATH_2 .. "/foo/bar/baz") == binary, + "Invalid copied file - root/foo/bar/baz" +) +assert( + fs.readFile(TEMP_ROOT_PATH_2 .. "/foo/fizz") == binary, + "Invalid copied file - root/foo/fizz" +) +assert( + fs.readFile(TEMP_ROOT_PATH_2 .. "/foo/buzz") == binary, + "Invalid copied file - root/foo/buzz" +) + +-- Finally, clean up after us for any subsequent tests + +fs.removeDir(TEMP_ROOT_PATH) +fs.removeDir(TEMP_ROOT_PATH_2)