use runmat_filesystem as vfs;
use std::io;
use std::path::{Path, PathBuf};
use glob::Pattern;
use runmat_builtins::{
BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
CharArray, Value,
};
use runmat_macros::runtime_builtin;
use crate::builtins::common::fs::{contains_wildcards, expand_user_path};
use crate::builtins::common::spec::{
BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
ReductionNaN, ResidencyPolicy, ShapeRequirements,
};
use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::repl_fs::copyfile")]
pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
name: "copyfile",
op_kind: GpuOpKind::Custom("io"),
supported_precisions: &[],
broadcast: BroadcastSemantics::None,
provider_hooks: &[],
constant_strategy: ConstantStrategy::InlineLiteral,
residency: ResidencyPolicy::GatherImmediately,
nan_mode: ReductionNaN::Include,
two_pass_threshold: None,
workgroup_size: None,
accepts_nan_mode: false,
notes:
"Host-only filesystem operation. GPU-resident path and flag arguments are gathered automatically before performing the copy.",
};
#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::repl_fs::copyfile")]
pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
name: "copyfile",
shape: ShapeRequirements::Any,
constant_strategy: ConstantStrategy::InlineLiteral,
elementwise: None,
reduction: None,
emits_nan: false,
notes:
"Filesystem side effects materialise immediately; metadata is registered for completeness.",
};
const BUILTIN_NAME: &str = "copyfile";
const COPYFILE_OUTPUT_STATUS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
name: "status",
ty: BuiltinParamType::NumericScalar,
arity: BuiltinParamArity::Required,
default: None,
description: "1 on success, 0 when copy fails.",
}];
const COPYFILE_OUTPUT_STATUS_MSG_MSGID: [BuiltinParamDescriptor; 3] = [
BuiltinParamDescriptor {
name: "status",
ty: BuiltinParamType::NumericScalar,
arity: BuiltinParamArity::Required,
default: None,
description: "1 on success, 0 when copy fails.",
},
BuiltinParamDescriptor {
name: "msg",
ty: BuiltinParamType::StringScalar,
arity: BuiltinParamArity::Required,
default: None,
description: "Diagnostic message for failures.",
},
BuiltinParamDescriptor {
name: "msgID",
ty: BuiltinParamType::StringScalar,
arity: BuiltinParamArity::Required,
default: None,
description: "Stable diagnostic identifier string.",
},
];
const COPYFILE_INPUTS_SOURCE_DEST: [BuiltinParamDescriptor; 2] = [
BuiltinParamDescriptor {
name: "source",
ty: BuiltinParamType::StringScalar,
arity: BuiltinParamArity::Required,
default: None,
description: "Source file/folder path or wildcard pattern.",
},
BuiltinParamDescriptor {
name: "destination",
ty: BuiltinParamType::StringScalar,
arity: BuiltinParamArity::Required,
default: None,
description: "Destination path.",
},
];
const COPYFILE_INPUTS_SOURCE_DEST_FLAG: [BuiltinParamDescriptor; 3] = [
BuiltinParamDescriptor {
name: "source",
ty: BuiltinParamType::StringScalar,
arity: BuiltinParamArity::Required,
default: None,
description: "Source file/folder path or wildcard pattern.",
},
BuiltinParamDescriptor {
name: "destination",
ty: BuiltinParamType::StringScalar,
arity: BuiltinParamArity::Required,
default: None,
description: "Destination path.",
},
BuiltinParamDescriptor {
name: "flag",
ty: BuiltinParamType::StringScalar,
arity: BuiltinParamArity::Required,
default: Some("\"f\""),
description: "Overwrite flag; only \"f\" is accepted.",
},
];
const COPYFILE_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
BuiltinSignatureDescriptor {
label: "status = copyfile(source, destination)",
inputs: ©FILE_INPUTS_SOURCE_DEST,
outputs: ©FILE_OUTPUT_STATUS,
},
BuiltinSignatureDescriptor {
label: "status = copyfile(source, destination, flag)",
inputs: ©FILE_INPUTS_SOURCE_DEST_FLAG,
outputs: ©FILE_OUTPUT_STATUS,
},
BuiltinSignatureDescriptor {
label: "[status, msg, msgID] = copyfile(source, destination)",
inputs: ©FILE_INPUTS_SOURCE_DEST,
outputs: ©FILE_OUTPUT_STATUS_MSG_MSGID,
},
BuiltinSignatureDescriptor {
label: "[status, msg, msgID] = copyfile(source, destination, flag)",
inputs: ©FILE_INPUTS_SOURCE_DEST_FLAG,
outputs: ©FILE_OUTPUT_STATUS_MSG_MSGID,
},
];
const COPYFILE_ERROR_NOT_ENOUGH_INPUTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.COPYFILE.NOT_ENOUGH_INPUTS",
identifier: Some("RunMat:copyfile:NotEnoughInputs"),
when: "Fewer than two input arguments are provided.",
message: "copyfile: not enough input arguments",
};
const COPYFILE_ERROR_TOO_MANY_INPUTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.COPYFILE.TOO_MANY_INPUTS",
identifier: Some("RunMat:copyfile:TooManyInputs"),
when: "More than three input arguments are provided.",
message: "copyfile: too many input arguments",
};
const COPYFILE_ERROR_SOURCE_ARG: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.COPYFILE.SOURCE_ARG",
identifier: Some("RunMat:copyfile:SourceArgType"),
when: "Source argument is not a character vector or string scalar.",
message: "copyfile: source must be a character vector or string scalar",
};
const COPYFILE_ERROR_DEST_ARG: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.COPYFILE.DEST_ARG",
identifier: Some("RunMat:copyfile:DestinationArgType"),
when: "Destination argument is not a character vector or string scalar.",
message: "copyfile: destination must be a character vector or string scalar",
};
const COPYFILE_ERROR_FLAG_ARG: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.COPYFILE.FLAG_ARG",
identifier: Some("RunMat:copyfile:FlagArgType"),
when: "Flag argument is not the scalar character 'f'.",
message: "copyfile: flag must be the character 'f' supplied as a char vector or string scalar",
};
const COPYFILE_RESULT_OS_ERROR: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.COPYFILE.OS_ERROR",
identifier: Some("RunMat:copyfile:OSError"),
when: "Filesystem copy operation fails.",
message: "copyfile: unable to copy",
};
const COPYFILE_RESULT_SOURCE_NOT_FOUND: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.COPYFILE.SOURCE_NOT_FOUND",
identifier: Some("RunMat:copyfile:FileDoesNotExist"),
when: "Source path or wildcard match does not exist.",
message: "copyfile: source not found",
};
const COPYFILE_RESULT_DEST_EXISTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.COPYFILE.DEST_EXISTS",
identifier: Some("RunMat:copyfile:DestinationExists"),
when: "Destination already exists and overwrite is not requested.",
message: "copyfile: destination already exists",
};
const COPYFILE_RESULT_DEST_MISSING: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.COPYFILE.DEST_MISSING",
identifier: Some("RunMat:copyfile:DestinationNotFound"),
when: "Destination directory is missing for wildcard source copies.",
message: "copyfile: destination folder not found",
};
const COPYFILE_RESULT_DEST_NOT_DIR: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.COPYFILE.DEST_NOT_DIR",
identifier: Some("RunMat:copyfile:DestinationNotDirectory"),
when: "Destination must be a directory for wildcard or directory copy modes.",
message: "copyfile: destination is not a directory",
};
const COPYFILE_RESULT_EMPTY_SOURCE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.COPYFILE.EMPTY_SOURCE",
identifier: Some("RunMat:copyfile:EmptySource"),
when: "Source path is empty.",
message: "Source file or folder name must not be empty.",
};
const COPYFILE_RESULT_EMPTY_DEST: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.COPYFILE.EMPTY_DEST",
identifier: Some("RunMat:copyfile:EmptyDestination"),
when: "Destination path is empty.",
message: "Destination file or folder name must not be empty.",
};
const COPYFILE_RESULT_PATTERN_ERROR: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.COPYFILE.PATTERN_ERROR",
identifier: Some("RunMat:copyfile:InvalidPattern"),
when: "Source wildcard pattern is invalid.",
message: "copyfile: invalid source pattern",
};
const COPYFILE_RESULT_SAME_PATH: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.COPYFILE.SAME_PATH",
identifier: Some("RunMat:copyfile:SourceEqualsDestination"),
when: "Source and destination resolve to the same path.",
message: "copyfile: source and destination are the same",
};
const COPYFILE_ERRORS: [BuiltinErrorDescriptor; 14] = [
COPYFILE_ERROR_NOT_ENOUGH_INPUTS,
COPYFILE_ERROR_TOO_MANY_INPUTS,
COPYFILE_ERROR_SOURCE_ARG,
COPYFILE_ERROR_DEST_ARG,
COPYFILE_ERROR_FLAG_ARG,
COPYFILE_RESULT_OS_ERROR,
COPYFILE_RESULT_SOURCE_NOT_FOUND,
COPYFILE_RESULT_DEST_EXISTS,
COPYFILE_RESULT_DEST_MISSING,
COPYFILE_RESULT_DEST_NOT_DIR,
COPYFILE_RESULT_EMPTY_SOURCE,
COPYFILE_RESULT_EMPTY_DEST,
COPYFILE_RESULT_PATTERN_ERROR,
COPYFILE_RESULT_SAME_PATH,
];
pub const COPYFILE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
signatures: ©FILE_SIGNATURES,
output_mode: BuiltinOutputMode::ByRequestedOutputCount,
completion_policy: BuiltinCompletionPolicy::Public,
errors: ©FILE_ERRORS,
};
fn copyfile_error_row(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
let mut builder = build_runtime_error(error.message).with_builtin(BUILTIN_NAME);
if let Some(identifier) = error.identifier {
builder = builder.with_identifier(identifier);
}
builder.build()
}
fn copyfile_error(message: impl Into<String>) -> RuntimeError {
build_runtime_error(message)
.with_builtin(BUILTIN_NAME)
.build()
}
fn map_control_flow(err: RuntimeError) -> RuntimeError {
let identifier = err.identifier().map(str::to_string);
let mut builder = build_runtime_error(format!("{BUILTIN_NAME}: {}", err.message()))
.with_builtin(BUILTIN_NAME)
.with_source(err);
if let Some(identifier) = identifier {
builder = builder.with_identifier(identifier);
}
builder.build()
}
#[runtime_builtin(
name = "copyfile",
category = "io/repl_fs",
summary = "Copy files or folders.",
keywords = "copyfile,copy file,copy folder,filesystem,status,message,messageid,force,overwrite",
accel = "cpu",
suppress_auto_output = true,
type_resolver(crate::builtins::io::type_resolvers::copyfile_type),
descriptor(crate::builtins::io::repl_fs::copyfile::COPYFILE_DESCRIPTOR),
builtin_path = "crate::builtins::io::repl_fs::copyfile"
)]
async fn copyfile_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
let eval = evaluate(&args).await?;
if let Some(out_count) = crate::output_count::current_output_count() {
if out_count == 0 {
return Ok(Value::OutputList(Vec::new()));
}
return Ok(crate::output_count::output_list_with_padding(
out_count,
eval.outputs(),
));
}
Ok(eval.first_output())
}
pub async fn evaluate(args: &[Value]) -> BuiltinResult<CopyfileResult> {
let gathered = gather_arguments(args).await?;
match gathered.len() {
0 | 1 => Err(copyfile_error_row(©FILE_ERROR_NOT_ENOUGH_INPUTS)),
2 => copy_operation(&gathered[0], &gathered[1], false).await,
3 => {
let force = parse_force_flag(&gathered[2])?;
copy_operation(&gathered[0], &gathered[1], force).await
}
_ => Err(copyfile_error_row(©FILE_ERROR_TOO_MANY_INPUTS)),
}
}
#[derive(Debug, Clone)]
pub struct CopyfileResult {
status: f64,
message: String,
message_id: String,
}
impl CopyfileResult {
fn success() -> Self {
Self {
status: 1.0,
message: String::new(),
message_id: String::new(),
}
}
fn failure(message: String, error: &'static BuiltinErrorDescriptor) -> Self {
Self {
status: 0.0,
message,
message_id: message_identifier(error).to_string(),
}
}
fn empty_source() -> Self {
Self::failure(
COPYFILE_RESULT_EMPTY_SOURCE.message.to_string(),
©FILE_RESULT_EMPTY_SOURCE,
)
}
fn empty_destination() -> Self {
Self::failure(
COPYFILE_RESULT_EMPTY_DEST.message.to_string(),
©FILE_RESULT_EMPTY_DEST,
)
}
fn source_not_found(display: &str) -> Self {
Self::failure(
format!("Source \"{}\" does not exist.", display),
©FILE_RESULT_SOURCE_NOT_FOUND,
)
}
fn destination_exists(display: &str) -> Self {
Self::failure(
format!(
"Cannot copy to \"{}\": destination already exists.",
display
),
©FILE_RESULT_DEST_EXISTS,
)
}
fn destination_missing(display: &str) -> Self {
Self::failure(
format!(
"Destination folder \"{}\" must exist when copying multiple sources.",
display
),
©FILE_RESULT_DEST_MISSING,
)
}
fn destination_not_directory(display: &str) -> Self {
Self::failure(
format!("Destination \"{}\" must refer to a folder.", display),
©FILE_RESULT_DEST_NOT_DIR,
)
}
fn same_path(display: &str) -> Self {
Self::failure(
format!("Cannot copy \"{}\" onto itself.", display),
©FILE_RESULT_SAME_PATH,
)
}
fn glob_pattern_error(pattern: &str, err: &str) -> Self {
Self::failure(
format!("Invalid source pattern \"{}\": {}", pattern, err),
©FILE_RESULT_PATTERN_ERROR,
)
}
fn os_error(source: &str, target: &str, err: &io::Error) -> Self {
Self::failure(
format!("Unable to copy \"{}\" to \"{}\": {}", source, target, err),
©FILE_RESULT_OS_ERROR,
)
}
pub fn first_output(&self) -> Value {
Value::Num(self.status)
}
pub fn outputs(&self) -> Vec<Value> {
vec![
Value::Num(self.status),
char_array_value(&self.message),
char_array_value(&self.message_id),
]
}
#[cfg(test)]
pub(crate) fn status(&self) -> f64 {
self.status
}
#[cfg(test)]
pub(crate) fn message(&self) -> &str {
&self.message
}
#[cfg(test)]
pub(crate) fn message_id(&self) -> &str {
&self.message_id
}
}
async fn copy_operation(
source: &Value,
destination: &Value,
force: bool,
) -> BuiltinResult<CopyfileResult> {
let source_raw = extract_path(source, ©FILE_ERROR_SOURCE_ARG)?;
if source_raw.is_empty() {
return Ok(CopyfileResult::empty_source());
}
let destination_raw = extract_path(destination, ©FILE_ERROR_DEST_ARG)?;
if destination_raw.is_empty() {
return Ok(CopyfileResult::empty_destination());
}
let source_expanded = expand_user_path(&source_raw, "copyfile").map_err(copyfile_error)?;
let destination_expanded =
expand_user_path(&destination_raw, "copyfile").map_err(copyfile_error)?;
if contains_wildcards(&source_expanded) {
Ok(copy_with_pattern(&source_expanded, &destination_expanded, force).await)
} else {
Ok(copy_single_source(&source_expanded, &destination_expanded, force).await)
}
}
async fn copy_single_source(source: &str, destination: &str, force: bool) -> CopyfileResult {
let source_path = PathBuf::from(source);
let source_meta = match vfs::metadata_async(&source_path).await {
Ok(meta) => meta,
Err(_) => return CopyfileResult::source_not_found(source),
};
let source_display = path_to_display(&source_path);
let destination_path = PathBuf::from(destination);
if same_physical_path(&source_path, &destination_path).await {
return CopyfileResult::same_path(&source_display);
}
let destination_meta = vfs::metadata_async(&destination_path).await.ok();
let mut target_path = destination_path.clone();
let mut remove_target = false;
let mut remove_is_dir = false;
if let Some(meta) = &destination_meta {
if meta.is_dir() {
let Some(name) = source_path.file_name() else {
return CopyfileResult::os_error(
source,
destination,
&io::Error::other("Cannot determine source file name"),
);
};
target_path = destination_path.join(name);
if same_physical_path(&source_path, &target_path).await {
return CopyfileResult::same_path(&source_display);
}
if source_meta.is_dir() && is_descendant(&source_path, &target_path).await {
return CopyfileResult::failure(
"Cannot copy a folder into one of its descendants.".to_string(),
©FILE_RESULT_OS_ERROR,
);
}
match vfs::metadata_async(&target_path).await {
Ok(existing) => {
if !force {
return CopyfileResult::destination_exists(&path_to_display(&target_path));
}
remove_target = true;
remove_is_dir = existing.is_dir();
}
Err(err) => {
if err.kind() != io::ErrorKind::NotFound {
return CopyfileResult::os_error(
source,
&path_to_display(&target_path),
&err,
);
}
}
}
} else {
if source_meta.is_dir() {
return CopyfileResult::destination_not_directory(destination);
}
if !force {
return CopyfileResult::destination_exists(destination);
}
remove_target = true;
remove_is_dir = false;
}
} else if source_meta.is_dir() {
if is_descendant(&source_path, &destination_path).await {
return CopyfileResult::failure(
"Cannot copy a folder into one of its descendants.".to_string(),
©FILE_RESULT_OS_ERROR,
);
}
}
let target_display = path_to_display(&target_path);
let plan = vec![CopyPlanEntry::new(
source_path,
source_display.clone(),
target_path,
target_display,
source_meta.is_dir(),
remove_target,
remove_is_dir,
)];
match execute_plan(&plan).await {
Ok(()) => CopyfileResult::success(),
Err(err) => CopyfileResult::os_error(&err.source_display, &err.target_display, &err.error),
}
}
async fn copy_with_pattern(pattern: &str, destination: &str, force: bool) -> CopyfileResult {
let pattern_path = Path::new(pattern);
let (base_dir, name_pattern) = match pattern_path.file_name() {
Some(name) => (
pattern_path.parent().unwrap_or_else(|| Path::new(".")),
name,
),
None => {
return CopyfileResult::glob_pattern_error(pattern, "pattern has no file name");
}
};
let matcher = match Pattern::new(&name_pattern.to_string_lossy()) {
Ok(matcher) => matcher,
Err(err) => return CopyfileResult::glob_pattern_error(pattern, err.msg),
};
let mut matches = Vec::new();
let entries = match vfs::read_dir_async(base_dir).await {
Ok(entries) => entries,
Err(err) => {
let display = path_to_display(base_dir);
return CopyfileResult::os_error(&display, destination, &err);
}
};
for entry in entries {
let file_name = entry.file_name().to_string_lossy();
if matcher.matches(&file_name) {
matches.push(entry.path().to_path_buf());
}
}
if matches.is_empty() {
return CopyfileResult::source_not_found(pattern);
}
let destination_path = PathBuf::from(destination);
let destination_meta = match vfs::metadata_async(&destination_path).await {
Ok(meta) => meta,
Err(_) => return CopyfileResult::destination_missing(destination),
};
if !destination_meta.is_dir() {
return CopyfileResult::destination_not_directory(destination);
}
let mut plan = Vec::with_capacity(matches.len());
for source_path in matches {
let display_source = path_to_display(&source_path);
let meta = match vfs::metadata_async(&source_path).await {
Ok(meta) => meta,
Err(_) => return CopyfileResult::source_not_found(&display_source),
};
let Some(name) = source_path.file_name() else {
return CopyfileResult::os_error(
&display_source,
destination,
&io::Error::other("Cannot determine source name"),
);
};
let target_path = destination_path.join(name);
if same_physical_path(&source_path, &target_path).await {
return CopyfileResult::same_path(&display_source);
}
let target_display = path_to_display(&target_path);
match vfs::metadata_async(&target_path).await {
Ok(existing) => {
if !force {
return CopyfileResult::destination_exists(&target_display);
}
plan.push(CopyPlanEntry::new(
source_path.clone(),
display_source.clone(),
target_path.clone(),
target_display,
meta.is_dir(),
true,
existing.is_dir(),
));
}
Err(err) => {
if err.kind() != io::ErrorKind::NotFound {
return CopyfileResult::os_error(&display_source, &target_display, &err);
}
plan.push(CopyPlanEntry::new(
source_path.clone(),
display_source,
target_path.clone(),
target_display,
meta.is_dir(),
false,
false,
));
}
}
}
if plan.is_empty() {
return CopyfileResult::success();
}
match execute_plan(&plan).await {
Ok(()) => CopyfileResult::success(),
Err(err) => CopyfileResult::os_error(&err.source_display, &err.target_display, &err.error),
}
}
#[derive(Debug, Clone)]
struct CopyPlanEntry {
source_path: PathBuf,
source_display: String,
target_path: PathBuf,
target_display: String,
source_is_dir: bool,
remove_target: bool,
remove_is_dir: bool,
}
impl CopyPlanEntry {
fn new(
source_path: PathBuf,
source_display: String,
target_path: PathBuf,
target_display: String,
source_is_dir: bool,
remove_target: bool,
remove_is_dir: bool,
) -> Self {
Self {
source_path,
source_display,
target_path,
target_display,
source_is_dir,
remove_target,
remove_is_dir,
}
}
}
struct CopyError {
source_display: String,
target_display: String,
error: io::Error,
}
async fn execute_plan(plan: &[CopyPlanEntry]) -> Result<(), CopyError> {
for entry in plan {
if entry.remove_target {
let result = if entry.remove_is_dir {
vfs::remove_dir_all_async(&entry.target_path).await
} else {
vfs::remove_file_async(&entry.target_path).await
};
if let Err(err) = result {
if err.kind() != io::ErrorKind::NotFound {
return Err(CopyError {
source_display: entry.source_display.clone(),
target_display: entry.target_display.clone(),
error: err,
});
}
}
}
if let Err(err) =
copy_path(&entry.source_path, &entry.target_path, entry.source_is_dir).await
{
return Err(CopyError {
source_display: entry.source_display.clone(),
target_display: entry.target_display.clone(),
error: err,
});
}
}
Ok(())
}
async fn copy_path(source: &Path, destination: &Path, is_directory: bool) -> io::Result<()> {
if is_directory {
copy_directory_recursive(source, destination).await
} else {
copy_file_to_path(source, destination).await
}
}
#[async_recursion::async_recursion(?Send)]
async fn copy_directory_recursive(source: &Path, destination: &Path) -> io::Result<()> {
ensure_parent_exists(destination).await?;
match vfs::metadata_async(destination).await {
Ok(meta) => {
if !meta.is_dir() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"Destination exists and is not a directory",
));
}
}
Err(err) => {
if err.kind() != io::ErrorKind::NotFound {
return Err(err);
}
vfs::create_dir_all_async(destination).await?;
}
}
if let Ok(metadata) = vfs::metadata_async(source).await {
let _ = vfs::set_readonly_async(destination, metadata.is_readonly()).await;
}
for entry in vfs::read_dir_async(source).await? {
let child_source = entry.path().to_path_buf();
let child_dest = destination.join(PathBuf::from(entry.file_name()));
let child_meta = vfs::metadata_async(&child_source).await?;
if child_meta.is_dir() {
copy_directory_recursive(&child_source, &child_dest).await?;
} else {
copy_file_to_path(&child_source, &child_dest).await?;
}
}
Ok(())
}
async fn copy_file_to_path(source: &Path, destination: &Path) -> io::Result<()> {
ensure_parent_exists(destination).await?;
vfs::copy_file(source, destination)?;
if let Ok(metadata) = vfs::metadata_async(source).await {
let _ = vfs::set_readonly_async(destination, metadata.is_readonly()).await;
}
Ok(())
}
async fn ensure_parent_exists(path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
if parent.as_os_str().is_empty() || parent == Path::new(".") {
return Ok(());
}
if vfs::metadata_async(parent).await.is_err() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("Destination parent \"{}\" does not exist", parent.display()),
));
}
}
Ok(())
}
async fn same_physical_path(a: &Path, b: &Path) -> bool {
if a == b {
return true;
}
match (
vfs::canonicalize_async(a).await,
vfs::canonicalize_async(b).await,
) {
(Ok(ca), Ok(cb)) => ca == cb,
_ => false,
}
}
async fn is_descendant(parent: &Path, candidate: &Path) -> bool {
if candidate.starts_with(parent) && candidate != parent {
return true;
}
match (
vfs::canonicalize_async(parent).await,
vfs::canonicalize_async(candidate).await,
) {
(Ok(parent_canon), Ok(candidate_canon)) => {
candidate_canon.starts_with(&parent_canon) && candidate_canon != parent_canon
}
_ => false,
}
}
fn parse_force_flag(value: &Value) -> BuiltinResult<bool> {
let text = extract_path(value, ©FILE_ERROR_FLAG_ARG)?;
if text.eq_ignore_ascii_case("f") {
Ok(true)
} else {
Err(copyfile_error_row(©FILE_ERROR_FLAG_ARG))
}
}
fn extract_path(value: &Value, error: &'static BuiltinErrorDescriptor) -> BuiltinResult<String> {
match value {
Value::String(text) => Ok(text.clone()),
Value::CharArray(array) => {
if array.rows == 1 {
Ok(array.data.iter().collect())
} else {
Err(copyfile_error_row(error))
}
}
Value::StringArray(array) => {
if array.data.len() == 1 {
Ok(array.data[0].clone())
} else {
Err(copyfile_error_row(error))
}
}
_ => Err(copyfile_error_row(error)),
}
}
fn message_identifier(error: &'static BuiltinErrorDescriptor) -> &'static str {
error.identifier.unwrap_or("")
}
async fn gather_arguments(args: &[Value]) -> BuiltinResult<Vec<Value>> {
let mut out = Vec::with_capacity(args.len());
for value in args {
out.push(
gather_if_needed_async(value)
.await
.map_err(map_control_flow)?,
);
}
Ok(out)
}
fn char_array_value(text: &str) -> Value {
Value::CharArray(CharArray::new_row(text))
}
fn path_to_display(path: &Path) -> String {
path.display().to_string()
}
#[cfg(test)]
pub(crate) mod tests {
use super::super::REPL_FS_TEST_LOCK;
use super::*;
use std::fs::{self, File};
use tempfile::tempdir;
fn evaluate(args: &[Value]) -> BuiltinResult<CopyfileResult> {
futures::executor::block_on(super::evaluate(args))
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn copyfile_descriptor_signatures_cover_core_forms() {
let labels: Vec<&str> = COPYFILE_DESCRIPTOR
.signatures
.iter()
.map(|sig| sig.label)
.collect();
assert!(labels.contains(&"status = copyfile(source, destination)"));
assert!(labels.contains(&"status = copyfile(source, destination, flag)"));
assert!(labels.contains(&"[status, msg, msgID] = copyfile(source, destination)"));
assert!(labels.contains(&"[status, msg, msgID] = copyfile(source, destination, flag)"));
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn copyfile_copies_file_to_new_name() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let source = temp.path().join("source.txt");
let dest = temp.path().join("dest.txt");
File::create(&source).expect("create source");
let eval = evaluate(&[
Value::from(source.to_string_lossy().to_string()),
Value::from(dest.to_string_lossy().to_string()),
])
.expect("copyfile");
assert_eq!(eval.status(), 1.0);
assert!(source.exists());
assert!(dest.exists());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn copyfile_copies_into_existing_directory() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let source = temp.path().join("report.txt");
let dest_dir = temp.path().join("reports");
fs::create_dir(&dest_dir).expect("create dest dir");
File::create(&source).expect("create source");
let eval = evaluate(&[
Value::from(source.to_string_lossy().to_string()),
Value::from(dest_dir.to_string_lossy().to_string()),
])
.expect("copyfile");
assert_eq!(eval.status(), 1.0);
assert!(source.exists());
assert!(dest_dir.join("report.txt").exists());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn copyfile_force_overwrites_existing_file() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let source = temp.path().join("draft.txt");
let dest = temp.path().join("final.txt");
File::create(&source).expect("create source");
File::create(&dest).expect("create dest");
let eval = evaluate(&[
Value::from(source.to_string_lossy().to_string()),
Value::from(dest.to_string_lossy().to_string()),
Value::from("f"),
])
.expect("copyfile");
assert_eq!(eval.status(), 1.0);
assert!(source.exists());
assert!(dest.exists());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn copyfile_without_force_fails_when_destination_exists() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let source = temp.path().join("draft.txt");
let dest = temp.path().join("final.txt");
File::create(&source).expect("create source");
File::create(&dest).expect("create dest");
let eval = evaluate(&[
Value::from(source.to_string_lossy().to_string()),
Value::from(dest.to_string_lossy().to_string()),
])
.expect("copyfile");
assert_eq!(eval.status(), 0.0);
assert_eq!(
eval.message_id(),
message_identifier(©FILE_RESULT_DEST_EXISTS)
);
assert!(
eval.message().contains("destination already exists"),
"expected descriptive destination-exists message"
);
assert!(source.exists());
assert!(dest.exists());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn copyfile_copies_folder_tree() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let source_dir = temp.path().join("data");
let nested = source_dir.join("raw");
fs::create_dir(&source_dir).expect("create source dir");
fs::create_dir(&nested).expect("create nested dir");
let file_path = nested.join("sample.txt");
File::create(&file_path).expect("create sample");
let dest_dir = temp.path().join("data_copy");
let eval = evaluate(&[
Value::from(source_dir.to_string_lossy().to_string()),
Value::from(dest_dir.to_string_lossy().to_string()),
])
.expect("copyfile");
assert_eq!(eval.status(), 1.0);
assert!(dest_dir.join("raw").exists());
assert!(dest_dir.join("raw").join("sample.txt").exists());
assert!(file_path.exists());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn copyfile_pattern_requires_existing_directory() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let file = temp.path().join("file.log");
File::create(&file).expect("create file");
let pattern = temp.path().join("*.log");
let dest = temp.path().join("missing");
let eval = evaluate(&[
Value::from(pattern.to_string_lossy().to_string()),
Value::from(dest.to_string_lossy().to_string()),
])
.expect("copyfile");
assert_eq!(eval.status(), 0.0);
assert_eq!(
eval.message_id(),
message_identifier(©FILE_RESULT_DEST_MISSING)
);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn copyfile_pattern_copies_multiple_files() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let alpha = temp.path().join("alpha.log");
let beta = temp.path().join("beta.log");
File::create(&alpha).expect("create alpha");
File::create(&beta).expect("create beta");
let dest_dir = temp.path().join("logs");
fs::create_dir(&dest_dir).expect("create dest dir");
let pattern = temp.path().join("*.log");
let eval = evaluate(&[
Value::from(pattern.to_string_lossy().to_string()),
Value::from(dest_dir.to_string_lossy().to_string()),
])
.expect("copyfile");
assert_eq!(eval.status(), 1.0);
assert!(dest_dir.join("alpha.log").exists());
assert!(dest_dir.join("beta.log").exists());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn copyfile_pattern_copies_all_matches() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let dest_dir = temp.path().join("logs");
fs::create_dir(&dest_dir).expect("create dest dir");
let file_a = temp.path().join("a.log");
let file_b = temp.path().join("b.log");
File::create(&file_a).expect("create a");
File::create(&file_b).expect("create b");
let pattern = temp.path().join("*.log");
let eval = evaluate(&[
Value::from(pattern.to_string_lossy().to_string()),
Value::from(dest_dir.to_string_lossy().to_string()),
])
.expect("copyfile");
assert_eq!(eval.status(), 1.0);
assert!(dest_dir.join("a.log").exists());
assert!(dest_dir.join("b.log").exists());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn copyfile_reports_missing_source() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let source = temp.path().join("missing.txt");
let dest = temp.path().join("dest.txt");
let eval = evaluate(&[
Value::from(source.to_string_lossy().to_string()),
Value::from(dest.to_string_lossy().to_string()),
])
.expect("copyfile");
assert_eq!(eval.status(), 0.0);
assert_eq!(
eval.message_id(),
message_identifier(©FILE_RESULT_SOURCE_NOT_FOUND)
);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn copyfile_outputs_char_arrays() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let source = temp.path().join("source.txt");
let dest = temp.path().join("dest.txt");
File::create(&source).expect("create source");
let eval = evaluate(&[
Value::from(source.to_string_lossy().to_string()),
Value::from(dest.to_string_lossy().to_string()),
])
.expect("copyfile");
let outputs = eval.outputs();
assert_eq!(outputs.len(), 3);
assert!(matches!(outputs[0], Value::Num(1.0)));
assert!(matches!(outputs[1], Value::CharArray(ref ca) if ca.cols == 0));
assert!(matches!(outputs[2], Value::CharArray(ref ca) if ca.cols == 0));
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn copyfile_rejects_invalid_flag() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let err = evaluate(&[Value::from("a"), Value::from("b"), Value::Num(1.0)])
.expect_err("expected error");
assert_eq!(err.message(), COPYFILE_ERROR_FLAG_ARG.message);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn copyfile_force_flag_accepts_uppercase_char_array() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let source = temp.path().join("draft.txt");
let dest = temp.path().join("final.txt");
File::create(&source).expect("create source");
File::create(&dest).expect("create dest");
let eval = evaluate(&[
Value::from(source.to_string_lossy().to_string()),
Value::from(dest.to_string_lossy().to_string()),
Value::CharArray(CharArray::new_row("F")),
])
.expect("copyfile");
assert_eq!(eval.status(), 1.0);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn copyfile_force_flag_accepts_uppercase_string() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let source = temp.path().join("draft.txt");
let dest = temp.path().join("final.txt");
File::create(&source).expect("create source");
File::create(&dest).expect("create dest");
let eval = evaluate(&[
Value::from(source.to_string_lossy().to_string()),
Value::from(dest.to_string_lossy().to_string()),
Value::from("F"),
])
.expect("copyfile");
assert_eq!(eval.status(), 1.0);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn copyfile_same_path_fails() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let temp = tempdir().expect("temp dir");
let source = temp.path().join("note.txt");
File::create(&source).expect("create source");
let eval = evaluate(&[
Value::from(source.to_string_lossy().to_string()),
Value::from(source.to_string_lossy().to_string()),
])
.expect("copyfile");
assert_eq!(eval.status(), 0.0);
assert_eq!(
eval.message_id(),
message_identifier(©FILE_RESULT_SAME_PATH)
);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn copyfile_reports_empty_source() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let eval = evaluate(&[Value::from(""), Value::from("dest.txt")]).expect("copyfile");
assert_eq!(eval.status(), 0.0);
assert_eq!(
eval.message_id(),
message_identifier(©FILE_RESULT_EMPTY_SOURCE)
);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn copyfile_reports_empty_destination() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let eval = evaluate(&[Value::from("source.txt"), Value::from("")]).expect("copyfile");
assert_eq!(eval.status(), 0.0);
assert_eq!(
eval.message_id(),
message_identifier(©FILE_RESULT_EMPTY_DEST)
);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn copyfile_reports_invalid_pattern() {
let _lock = REPL_FS_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let eval = evaluate(&[Value::from("[*.txt"), Value::from("dest")]).expect("copyfile");
assert_eq!(eval.status(), 0.0);
assert_eq!(
eval.message_id(),
message_identifier(©FILE_RESULT_PATTERN_ERROR)
);
}
}