Skip to main content

runmat_runtime/builtins/io/repl_fs/
movefile.rs

1//! MATLAB-compatible `movefile` builtin for RunMat.
2
3use runmat_filesystem as vfs;
4use std::io;
5use std::path::{Path, PathBuf};
6
7use glob::Pattern;
8use runmat_builtins::{CharArray, Value};
9use runmat_macros::runtime_builtin;
10
11use crate::builtins::common::fs::{contains_wildcards, expand_user_path};
12use crate::builtins::common::spec::{
13    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
14    ReductionNaN, ResidencyPolicy, ShapeRequirements,
15};
16use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
17
18const MESSAGE_ID_OS_ERROR: &str = "RunMat:movefile:OSError";
19const MESSAGE_ID_SOURCE_NOT_FOUND: &str = "RunMat:movefile:FileDoesNotExist";
20const MESSAGE_ID_DEST_EXISTS: &str = "RunMat:movefile:DestinationExists";
21const MESSAGE_ID_DEST_MISSING: &str = "RunMat:movefile:DestinationNotFound";
22const MESSAGE_ID_DEST_NOT_DIR: &str = "RunMat:movefile:DestinationNotDirectory";
23const MESSAGE_ID_EMPTY_SOURCE: &str = "RunMat:movefile:EmptySource";
24const MESSAGE_ID_EMPTY_DEST: &str = "RunMat:movefile:EmptyDestination";
25const MESSAGE_ID_PATTERN_ERROR: &str = "RunMat:movefile:InvalidPattern";
26
27const ERR_SOURCE_ARG: &str = "movefile: source must be a character vector or string scalar";
28const ERR_DEST_ARG: &str = "movefile: destination must be a character vector or string scalar";
29const ERR_FLAG_ARG: &str =
30    "movefile: flag must be the character 'f' supplied as a char vector or string scalar";
31
32#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::repl_fs::movefile")]
33pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
34    name: "movefile",
35    op_kind: GpuOpKind::Custom("io"),
36    supported_precisions: &[],
37    broadcast: BroadcastSemantics::None,
38    provider_hooks: &[],
39    constant_strategy: ConstantStrategy::InlineLiteral,
40    residency: ResidencyPolicy::GatherImmediately,
41    nan_mode: ReductionNaN::Include,
42    two_pass_threshold: None,
43    workgroup_size: None,
44    accepts_nan_mode: false,
45    notes:
46        "Host-only filesystem builtin. GPU-resident path and flag arguments are gathered automatically before moving files.",
47};
48
49#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::repl_fs::movefile")]
50pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
51    name: "movefile",
52    shape: ShapeRequirements::Any,
53    constant_strategy: ConstantStrategy::InlineLiteral,
54    elementwise: None,
55    reduction: None,
56    emits_nan: false,
57    notes: "Filesystem side-effects materialise immediately; metadata registered for completeness.",
58};
59
60const BUILTIN_NAME: &str = "movefile";
61
62fn movefile_error(message: impl Into<String>) -> RuntimeError {
63    build_runtime_error(message)
64        .with_builtin(BUILTIN_NAME)
65        .build()
66}
67
68fn map_control_flow(err: RuntimeError) -> RuntimeError {
69    let identifier = err.identifier().map(str::to_string);
70    let mut builder = build_runtime_error(format!("{BUILTIN_NAME}: {}", err.message()))
71        .with_builtin(BUILTIN_NAME)
72        .with_source(err);
73    if let Some(identifier) = identifier {
74        builder = builder.with_identifier(identifier);
75    }
76    builder.build()
77}
78
79#[runtime_builtin(
80    name = "movefile",
81    category = "io/repl_fs",
82    summary = "Move or rename files and folders with MATLAB-compatible status, message, and message ID outputs.",
83    keywords = "movefile,rename,move file,filesystem,status,message,messageid,force,overwrite",
84    accel = "cpu",
85    suppress_auto_output = true,
86    type_resolver(crate::builtins::io::type_resolvers::movefile_type),
87    builtin_path = "crate::builtins::io::repl_fs::movefile"
88)]
89async fn movefile_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
90    let eval = evaluate(&args).await?;
91    if let Some(out_count) = crate::output_count::current_output_count() {
92        if out_count == 0 {
93            return Ok(Value::OutputList(Vec::new()));
94        }
95        return Ok(crate::output_count::output_list_with_padding(
96            out_count,
97            eval.outputs(),
98        ));
99    }
100    Ok(eval.first_output())
101}
102
103/// Evaluate `movefile` once and expose all outputs.
104pub async fn evaluate(args: &[Value]) -> BuiltinResult<MovefileResult> {
105    let gathered = gather_arguments(args).await?;
106    match gathered.len() {
107        0 | 1 => Err(movefile_error("movefile: not enough input arguments")),
108        2 => move_operation(&gathered[0], &gathered[1], false).await,
109        3 => {
110            let force = parse_force_flag(&gathered[2])?;
111            move_operation(&gathered[0], &gathered[1], force).await
112        }
113        _ => Err(movefile_error("movefile: too many input arguments")),
114    }
115}
116
117#[derive(Debug, Clone)]
118pub struct MovefileResult {
119    status: f64,
120    message: String,
121    message_id: String,
122}
123
124impl MovefileResult {
125    fn success() -> Self {
126        Self {
127            status: 1.0,
128            message: String::new(),
129            message_id: String::new(),
130        }
131    }
132
133    fn failure(message: String, message_id: &str) -> Self {
134        Self {
135            status: 0.0,
136            message,
137            message_id: message_id.to_string(),
138        }
139    }
140
141    fn empty_source() -> Self {
142        Self::failure(
143            "Source file or folder name must not be empty.".to_string(),
144            MESSAGE_ID_EMPTY_SOURCE,
145        )
146    }
147
148    fn empty_destination() -> Self {
149        Self::failure(
150            "Destination file or folder name must not be empty.".to_string(),
151            MESSAGE_ID_EMPTY_DEST,
152        )
153    }
154
155    fn source_not_found(display: &str) -> Self {
156        Self::failure(
157            format!("Source \"{}\" does not exist.", display),
158            MESSAGE_ID_SOURCE_NOT_FOUND,
159        )
160    }
161
162    fn destination_exists(display: &str) -> Self {
163        Self::failure(
164            format!(
165                "Cannot move to \"{}\": destination already exists.",
166                display
167            ),
168            MESSAGE_ID_DEST_EXISTS,
169        )
170    }
171
172    fn destination_missing(display: &str) -> Self {
173        Self::failure(
174            format!(
175                "Destination folder \"{}\" must exist when moving multiple sources.",
176                display
177            ),
178            MESSAGE_ID_DEST_MISSING,
179        )
180    }
181
182    fn destination_not_directory(display: &str) -> Self {
183        Self::failure(
184            format!("Destination \"{}\" must refer to a folder.", display),
185            MESSAGE_ID_DEST_NOT_DIR,
186        )
187    }
188
189    fn glob_pattern_error(pattern: &str, err: &str) -> Self {
190        Self::failure(
191            format!("Invalid source pattern \"{}\": {}", pattern, err),
192            MESSAGE_ID_PATTERN_ERROR,
193        )
194    }
195
196    fn os_error(source: &str, target: &str, err: &io::Error) -> Self {
197        Self::failure(
198            format!("Unable to move \"{}\" to \"{}\": {}", source, target, err),
199            MESSAGE_ID_OS_ERROR,
200        )
201    }
202
203    pub fn first_output(&self) -> Value {
204        Value::Num(self.status)
205    }
206
207    pub fn outputs(&self) -> Vec<Value> {
208        vec![
209            Value::Num(self.status),
210            char_array_value(&self.message),
211            char_array_value(&self.message_id),
212        ]
213    }
214
215    #[cfg(test)]
216    pub(crate) fn status(&self) -> f64 {
217        self.status
218    }
219
220    #[cfg(test)]
221    pub(crate) fn message(&self) -> &str {
222        &self.message
223    }
224
225    #[cfg(test)]
226    pub(crate) fn message_id(&self) -> &str {
227        &self.message_id
228    }
229}
230
231async fn move_operation(
232    source: &Value,
233    destination: &Value,
234    force: bool,
235) -> BuiltinResult<MovefileResult> {
236    let source_raw = extract_path(source, ERR_SOURCE_ARG)?;
237    if source_raw.is_empty() {
238        return Ok(MovefileResult::empty_source());
239    }
240
241    let destination_raw = extract_path(destination, ERR_DEST_ARG)?;
242    if destination_raw.is_empty() {
243        return Ok(MovefileResult::empty_destination());
244    }
245
246    let source_expanded = expand_user_path(&source_raw, "movefile").map_err(movefile_error)?;
247    let destination_expanded =
248        expand_user_path(&destination_raw, "movefile").map_err(movefile_error)?;
249
250    if contains_wildcards(&source_expanded) {
251        Ok(move_with_pattern(&source_expanded, &destination_expanded, force).await)
252    } else {
253        Ok(move_single_source(&source_expanded, &destination_expanded, force).await)
254    }
255}
256
257async fn move_single_source(source: &str, destination: &str, force: bool) -> MovefileResult {
258    let source_path = PathBuf::from(source);
259    if vfs::metadata_async(&source_path).await.is_err() {
260        return MovefileResult::source_not_found(source);
261    }
262
263    let destination_path = PathBuf::from(destination);
264    if destination_path == source_path {
265        return MovefileResult::success();
266    }
267
268    let destination_meta = vfs::metadata_async(&destination_path).await.ok();
269    let mut target_path = destination_path.clone();
270    let mut remove_target = false;
271    let mut remove_is_dir = false;
272
273    if let Some(meta) = &destination_meta {
274        if meta.is_dir() {
275            let Some(name) = source_path.file_name() else {
276                return MovefileResult::os_error(
277                    source,
278                    destination,
279                    &io::Error::other("Cannot determine source file name"),
280                );
281            };
282            target_path = destination_path.join(name);
283            if target_path == source_path {
284                return MovefileResult::success();
285            }
286            match vfs::metadata_async(&target_path).await {
287                Ok(existing) => {
288                    if !force {
289                        return MovefileResult::destination_exists(&path_to_display(&target_path));
290                    }
291                    remove_target = true;
292                    remove_is_dir = existing.is_dir();
293                }
294                Err(err) => {
295                    if err.kind() != io::ErrorKind::NotFound {
296                        return MovefileResult::os_error(
297                            source,
298                            &path_to_display(&target_path),
299                            &err,
300                        );
301                    }
302                }
303            }
304        } else if !force {
305            if destination_path == source_path {
306                return MovefileResult::success();
307            }
308            return MovefileResult::destination_exists(destination);
309        } else {
310            remove_target = true;
311            remove_is_dir = meta.is_dir();
312        }
313    }
314
315    let source_display = path_to_display(&source_path);
316    let target_display = path_to_display(&target_path);
317    let plan = vec![MovePlanEntry::new(
318        source_path,
319        source_display,
320        target_path,
321        target_display,
322        remove_target,
323        remove_is_dir,
324    )];
325
326    match execute_plan(&plan).await {
327        Ok(()) => MovefileResult::success(),
328        Err(err) => MovefileResult::os_error(&err.source_display, &err.target_display, &err.error),
329    }
330}
331
332async fn move_with_pattern(pattern: &str, destination: &str, force: bool) -> MovefileResult {
333    let pattern_path = Path::new(pattern);
334    let (base_dir, name_pattern) = match pattern_path.file_name() {
335        Some(name) => (
336            pattern_path.parent().unwrap_or_else(|| Path::new(".")),
337            name,
338        ),
339        None => {
340            return MovefileResult::glob_pattern_error(pattern, "pattern has no file name");
341        }
342    };
343    let matcher = match Pattern::new(&name_pattern.to_string_lossy()) {
344        Ok(matcher) => matcher,
345        Err(err) => return MovefileResult::glob_pattern_error(pattern, err.msg),
346    };
347
348    let mut matches = Vec::new();
349    let entries = match vfs::read_dir_async(base_dir).await {
350        Ok(entries) => entries,
351        Err(err) => {
352            let display = path_to_display(base_dir);
353            return MovefileResult::os_error(&display, destination, &err);
354        }
355    };
356
357    for entry in entries {
358        let file_name = entry.file_name().to_string_lossy();
359        if matcher.matches(&file_name) {
360            matches.push(entry.path().to_path_buf());
361        }
362    }
363
364    if matches.is_empty() {
365        return MovefileResult::source_not_found(pattern);
366    }
367
368    let destination_path = PathBuf::from(destination);
369    let destination_meta = match vfs::metadata_async(&destination_path).await {
370        Ok(meta) => meta,
371        Err(_) => return MovefileResult::destination_missing(destination),
372    };
373
374    if !destination_meta.is_dir() {
375        return MovefileResult::destination_not_directory(destination);
376    }
377
378    let mut plan = Vec::with_capacity(matches.len());
379    for source_path in matches {
380        let display_source = path_to_display(&source_path);
381        if vfs::metadata_async(&source_path).await.is_err() {
382            return MovefileResult::source_not_found(&display_source);
383        }
384        let Some(name) = source_path.file_name() else {
385            return MovefileResult::os_error(
386                &display_source,
387                destination,
388                &io::Error::other("Cannot determine source name"),
389            );
390        };
391        let target_path = destination_path.join(name);
392        if target_path == source_path {
393            continue;
394        }
395        let target_display = path_to_display(&target_path);
396        match vfs::metadata_async(&target_path).await {
397            Ok(existing) => {
398                if !force {
399                    return MovefileResult::destination_exists(&target_display);
400                }
401                plan.push(MovePlanEntry::new(
402                    source_path.clone(),
403                    display_source.clone(),
404                    target_path.clone(),
405                    target_display,
406                    true,
407                    existing.is_dir(),
408                ));
409            }
410            Err(err) => {
411                if err.kind() != io::ErrorKind::NotFound {
412                    return MovefileResult::os_error(&display_source, &target_display, &err);
413                }
414                plan.push(MovePlanEntry::new(
415                    source_path.clone(),
416                    display_source,
417                    target_path.clone(),
418                    target_display,
419                    false,
420                    false,
421                ));
422            }
423        }
424    }
425
426    match execute_plan(&plan).await {
427        Ok(()) => MovefileResult::success(),
428        Err(err) => MovefileResult::os_error(&err.source_display, &err.target_display, &err.error),
429    }
430}
431
432#[derive(Debug, Clone)]
433struct MovePlanEntry {
434    source_path: PathBuf,
435    source_display: String,
436    target_path: PathBuf,
437    target_display: String,
438    remove_target: bool,
439    remove_is_dir: bool,
440}
441
442impl MovePlanEntry {
443    fn new(
444        source_path: PathBuf,
445        source_display: String,
446        target_path: PathBuf,
447        target_display: String,
448        remove_target: bool,
449        remove_is_dir: bool,
450    ) -> Self {
451        Self {
452            source_path,
453            source_display,
454            target_path,
455            target_display,
456            remove_target,
457            remove_is_dir,
458        }
459    }
460}
461
462struct MoveError {
463    source_display: String,
464    target_display: String,
465    error: io::Error,
466}
467
468async fn execute_plan(plan: &[MovePlanEntry]) -> Result<(), MoveError> {
469    for entry in plan {
470        if entry.remove_target {
471            let result = if entry.remove_is_dir {
472                vfs::remove_dir_all_async(&entry.target_path).await
473            } else {
474                vfs::remove_file_async(&entry.target_path).await
475            };
476            if let Err(err) = result {
477                if err.kind() != io::ErrorKind::NotFound {
478                    return Err(MoveError {
479                        source_display: entry.source_display.clone(),
480                        target_display: entry.target_display.clone(),
481                        error: err,
482                    });
483                }
484            }
485        }
486
487        if let Err(err) = vfs::rename_async(&entry.source_path, &entry.target_path).await {
488            return Err(MoveError {
489                source_display: entry.source_display.clone(),
490                target_display: entry.target_display.clone(),
491                error: err,
492            });
493        }
494    }
495
496    Ok(())
497}
498
499fn parse_force_flag(value: &Value) -> BuiltinResult<bool> {
500    let text = extract_path(value, ERR_FLAG_ARG)?;
501    if text.eq_ignore_ascii_case("f") {
502        Ok(true)
503    } else {
504        Err(movefile_error(ERR_FLAG_ARG))
505    }
506}
507
508fn extract_path(value: &Value, error_message: &str) -> BuiltinResult<String> {
509    match value {
510        Value::String(text) => Ok(text.clone()),
511        Value::CharArray(array) => {
512            if array.rows == 1 {
513                Ok(array.data.iter().collect())
514            } else {
515                Err(movefile_error(error_message))
516            }
517        }
518        Value::StringArray(array) => {
519            if array.data.len() == 1 {
520                Ok(array.data[0].clone())
521            } else {
522                Err(movefile_error(error_message))
523            }
524        }
525        _ => Err(movefile_error(error_message)),
526    }
527}
528
529async fn gather_arguments(args: &[Value]) -> BuiltinResult<Vec<Value>> {
530    let mut out = Vec::with_capacity(args.len());
531    for value in args {
532        out.push(
533            gather_if_needed_async(value)
534                .await
535                .map_err(map_control_flow)?,
536        );
537    }
538    Ok(out)
539}
540
541fn char_array_value(text: &str) -> Value {
542    Value::CharArray(CharArray::new_row(text))
543}
544
545fn path_to_display(path: &Path) -> String {
546    path.display().to_string()
547}
548
549#[cfg(test)]
550pub(crate) mod tests {
551    use super::super::REPL_FS_TEST_LOCK;
552    use super::*;
553    use std::fs::{self, File};
554    use tempfile::tempdir;
555
556    fn evaluate(args: &[Value]) -> BuiltinResult<MovefileResult> {
557        futures::executor::block_on(super::evaluate(args))
558    }
559
560    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
561    #[test]
562    fn movefile_renames_file() {
563        let _lock = REPL_FS_TEST_LOCK
564            .lock()
565            .unwrap_or_else(|poison| poison.into_inner());
566
567        let temp = tempdir().expect("temp dir");
568        let source = temp.path().join("source.txt");
569        let dest = temp.path().join("dest.txt");
570        File::create(&source).expect("create source");
571
572        let eval = evaluate(&[
573            Value::from(source.to_string_lossy().to_string()),
574            Value::from(dest.to_string_lossy().to_string()),
575        ])
576        .expect("movefile");
577        assert_eq!(eval.status(), 1.0);
578        assert!(!source.exists());
579        assert!(dest.exists());
580    }
581
582    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
583    #[test]
584    fn movefile_moves_into_existing_directory() {
585        let _lock = REPL_FS_TEST_LOCK
586            .lock()
587            .unwrap_or_else(|poison| poison.into_inner());
588
589        let temp = tempdir().expect("temp dir");
590        let source = temp.path().join("report.txt");
591        let dest_dir = temp.path().join("reports");
592        fs::create_dir(&dest_dir).expect("create dest dir");
593        File::create(&source).expect("create source");
594
595        let eval = evaluate(&[
596            Value::from(source.to_string_lossy().to_string()),
597            Value::from(dest_dir.to_string_lossy().to_string()),
598        ])
599        .expect("movefile");
600        assert_eq!(eval.status(), 1.0);
601        assert!(dest_dir.join("report.txt").exists());
602    }
603
604    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
605    #[test]
606    fn movefile_force_overwrites_existing_file() {
607        let _lock = REPL_FS_TEST_LOCK
608            .lock()
609            .unwrap_or_else(|poison| poison.into_inner());
610
611        let temp = tempdir().expect("temp dir");
612        let source = temp.path().join("draft.txt");
613        let dest = temp.path().join("final.txt");
614        File::create(&source).expect("create source");
615        File::create(&dest).expect("create dest");
616
617        let eval = evaluate(&[
618            Value::from(source.to_string_lossy().to_string()),
619            Value::from(dest.to_string_lossy().to_string()),
620            Value::from("f"),
621        ])
622        .expect("movefile");
623        assert_eq!(eval.status(), 1.0);
624        assert!(!source.exists());
625        assert!(dest.exists());
626    }
627
628    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
629    #[test]
630    fn movefile_without_force_preserves_existing_file() {
631        let _lock = REPL_FS_TEST_LOCK
632            .lock()
633            .unwrap_or_else(|poison| poison.into_inner());
634
635        let temp = tempdir().expect("temp dir");
636        let source = temp.path().join("draft.txt");
637        let dest = temp.path().join("final.txt");
638        File::create(&source).expect("create source");
639        File::create(&dest).expect("create dest");
640
641        let eval = evaluate(&[
642            Value::from(source.to_string_lossy().to_string()),
643            Value::from(dest.to_string_lossy().to_string()),
644        ])
645        .expect("movefile");
646        assert_eq!(eval.status(), 0.0);
647        assert_eq!(eval.message_id(), MESSAGE_ID_DEST_EXISTS);
648        assert!(eval.message().contains("destination already exists."));
649        assert!(source.exists());
650        assert!(dest.exists());
651    }
652
653    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
654    #[test]
655    fn movefile_moves_multiple_files_with_wildcard() {
656        let _lock = REPL_FS_TEST_LOCK
657            .lock()
658            .unwrap_or_else(|poison| poison.into_inner());
659
660        let temp = tempdir().expect("temp dir");
661        let dest_dir = temp.path().join("logs");
662        fs::create_dir(&dest_dir).expect("create dest dir");
663        let file_a = temp.path().join("a.log");
664        let file_b = temp.path().join("b.log");
665        File::create(&file_a).expect("create a");
666        File::create(&file_b).expect("create b");
667
668        let pattern = temp.path().join("*.log");
669        let eval = evaluate(&[
670            Value::from(pattern.to_string_lossy().to_string()),
671            Value::from(dest_dir.to_string_lossy().to_string()),
672        ])
673        .expect("movefile");
674        assert_eq!(eval.status(), 1.0);
675        assert!(dest_dir.join("a.log").exists());
676        assert!(dest_dir.join("b.log").exists());
677    }
678
679    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
680    #[test]
681    fn movefile_reports_missing_source() {
682        let _lock = REPL_FS_TEST_LOCK
683            .lock()
684            .unwrap_or_else(|poison| poison.into_inner());
685
686        let temp = tempdir().expect("temp dir");
687        let source = temp.path().join("missing.txt");
688        let dest = temp.path().join("dest.txt");
689
690        let eval = evaluate(&[
691            Value::from(source.to_string_lossy().to_string()),
692            Value::from(dest.to_string_lossy().to_string()),
693        ])
694        .expect("movefile");
695        assert_eq!(eval.status(), 0.0);
696        assert_eq!(eval.message_id(), MESSAGE_ID_SOURCE_NOT_FOUND);
697        assert!(eval.message().contains("does not exist"));
698    }
699
700    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
701    #[test]
702    fn movefile_outputs_char_arrays() {
703        let _lock = REPL_FS_TEST_LOCK
704            .lock()
705            .unwrap_or_else(|poison| poison.into_inner());
706
707        let temp = tempdir().expect("temp dir");
708        let source = temp.path().join("source.txt");
709        let dest = temp.path().join("dest.txt");
710        File::create(&source).expect("create source");
711
712        let eval = evaluate(&[
713            Value::from(source.to_string_lossy().to_string()),
714            Value::from(dest.to_string_lossy().to_string()),
715        ])
716        .expect("movefile");
717        let outputs = eval.outputs();
718        assert_eq!(outputs.len(), 3);
719        assert!(matches!(outputs[0], Value::Num(1.0)));
720        assert!(matches!(outputs[1], Value::CharArray(ref ca) if ca.cols == 0));
721        assert!(matches!(outputs[2], Value::CharArray(ref ca) if ca.cols == 0));
722    }
723
724    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
725    #[test]
726    fn movefile_rejects_invalid_flag() {
727        let _lock = REPL_FS_TEST_LOCK
728            .lock()
729            .unwrap_or_else(|poison| poison.into_inner());
730
731        let err = evaluate(&[Value::from("a"), Value::from("b"), Value::Num(1.0)])
732            .expect_err("expected error");
733        assert_eq!(err.message(), ERR_FLAG_ARG);
734    }
735
736    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
737    #[test]
738    fn movefile_force_flag_accepts_uppercase_char_array() {
739        let _lock = REPL_FS_TEST_LOCK
740            .lock()
741            .unwrap_or_else(|poison| poison.into_inner());
742
743        let temp = tempdir().expect("temp dir");
744        let source = temp.path().join("draft.txt");
745        let dest = temp.path().join("final.txt");
746        File::create(&source).expect("create source");
747        File::create(&dest).expect("create dest");
748
749        let eval = evaluate(&[
750            Value::from(source.to_string_lossy().to_string()),
751            Value::from(dest.to_string_lossy().to_string()),
752            Value::CharArray(CharArray::new_row("F")),
753        ])
754        .expect("movefile");
755        assert_eq!(eval.status(), 1.0);
756    }
757
758    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
759    #[test]
760    fn movefile_same_path_is_success() {
761        let _lock = REPL_FS_TEST_LOCK
762            .lock()
763            .unwrap_or_else(|poison| poison.into_inner());
764
765        let temp = tempdir().expect("temp dir");
766        let source = temp.path().join("note.txt");
767        File::create(&source).expect("create source");
768
769        let eval = evaluate(&[
770            Value::from(source.to_string_lossy().to_string()),
771            Value::from(source.to_string_lossy().to_string()),
772        ])
773        .expect("movefile");
774        assert_eq!(eval.status(), 1.0);
775        assert!(source.exists());
776    }
777
778    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
779    #[test]
780    fn movefile_moving_into_same_directory_is_success() {
781        let _lock = REPL_FS_TEST_LOCK
782            .lock()
783            .unwrap_or_else(|poison| poison.into_inner());
784
785        let temp = tempdir().expect("temp dir");
786        let dir = temp.path().join("docs");
787        fs::create_dir(&dir).expect("create dir");
788        let source = dir.join("readme.txt");
789        File::create(&source).expect("create source");
790
791        let eval = evaluate(&[
792            Value::from(source.to_string_lossy().to_string()),
793            Value::from(dir.to_string_lossy().to_string()),
794        ])
795        .expect("movefile");
796        assert_eq!(eval.status(), 1.0);
797        assert!(dir.join("readme.txt").exists());
798    }
799
800    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
801    #[test]
802    fn movefile_reports_empty_source() {
803        let _lock = REPL_FS_TEST_LOCK
804            .lock()
805            .unwrap_or_else(|poison| poison.into_inner());
806
807        let eval = evaluate(&[Value::from(""), Value::from("dest.txt")]).expect("movefile");
808        assert_eq!(eval.status(), 0.0);
809        assert_eq!(eval.message_id(), MESSAGE_ID_EMPTY_SOURCE);
810    }
811
812    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
813    #[test]
814    fn movefile_reports_empty_destination() {
815        let _lock = REPL_FS_TEST_LOCK
816            .lock()
817            .unwrap_or_else(|poison| poison.into_inner());
818
819        let eval = evaluate(&[Value::from("source.txt"), Value::from("")]).expect("movefile");
820        assert_eq!(eval.status(), 0.0);
821        assert_eq!(eval.message_id(), MESSAGE_ID_EMPTY_DEST);
822    }
823
824    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
825    #[test]
826    fn movefile_requires_existing_destination_directory_for_pattern() {
827        let _lock = REPL_FS_TEST_LOCK
828            .lock()
829            .unwrap_or_else(|poison| poison.into_inner());
830
831        let temp = tempdir().expect("temp dir");
832        let file = temp.path().join("file.log");
833        File::create(&file).expect("create file");
834        let pattern = temp.path().join("*.log");
835        let dest = temp.path().join("missing");
836
837        let eval = evaluate(&[
838            Value::from(pattern.to_string_lossy().to_string()),
839            Value::from(dest.to_string_lossy().to_string()),
840        ])
841        .expect("movefile");
842        assert_eq!(eval.status(), 0.0);
843        assert_eq!(eval.message_id(), MESSAGE_ID_DEST_MISSING);
844    }
845
846    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
847    #[test]
848    fn movefile_requires_directory_destination_for_pattern() {
849        let _lock = REPL_FS_TEST_LOCK
850            .lock()
851            .unwrap_or_else(|poison| poison.into_inner());
852
853        let temp = tempdir().expect("temp dir");
854        let file = temp.path().join("file.log");
855        let dest_file = temp.path().join("dest.log");
856        File::create(&file).expect("create file");
857        File::create(&dest_file).expect("create dest");
858        let pattern = temp.path().join("*.log");
859
860        let eval = evaluate(&[
861            Value::from(pattern.to_string_lossy().to_string()),
862            Value::from(dest_file.to_string_lossy().to_string()),
863        ])
864        .expect("movefile");
865        assert_eq!(eval.status(), 0.0);
866        assert_eq!(eval.message_id(), MESSAGE_ID_DEST_NOT_DIR);
867    }
868
869    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
870    #[test]
871    fn movefile_reports_invalid_pattern() {
872        let _lock = REPL_FS_TEST_LOCK
873            .lock()
874            .unwrap_or_else(|poison| poison.into_inner());
875
876        let eval = evaluate(&[
877            Value::from("[*.txt"), // unmatched '[' produces glob PatternError
878            Value::from("dest"),
879        ])
880        .expect("movefile");
881        assert_eq!(eval.status(), 0.0);
882        assert_eq!(eval.message_id(), MESSAGE_ID_PATTERN_ERROR);
883    }
884}