Skip to main content

mvx/
lib.rs

1use anyhow::{Context, bail, ensure};
2use colored::Colorize;
3use log::LevelFilter;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::sync::atomic::{AtomicBool, Ordering};
8
9mod dir;
10mod file;
11
12#[derive(Debug, Clone, Copy)]
13pub enum SourceKind {
14    File,
15    Dir,
16}
17
18impl SourceKind {
19    #[must_use]
20    pub(crate) fn done_arrow(self) -> colored::ColoredString {
21        match self {
22            Self::File => "→",
23            Self::Dir => "↣",
24        }
25        .green()
26        .bold()
27    }
28}
29
30#[derive(Debug, Clone, Copy)]
31pub enum MoveOrCopy {
32    Move,
33    Copy,
34}
35
36impl MoveOrCopy {
37    #[must_use]
38    pub const fn arrow(&self) -> &'static str {
39        match self {
40            Self::Move => "->",
41            Self::Copy => "=>",
42        }
43    }
44
45    #[must_use]
46    pub const fn progress_chars(&self) -> &'static str {
47        match self {
48            Self::Move => "->-",
49            Self::Copy => "=>=",
50        }
51    }
52}
53
54#[derive(Debug, Clone, Copy, Default)]
55pub(crate) struct TransferStats {
56    pub io_bytes: u64,
57    pub fast_path_file_count: u64,
58    pub fast_path_dir_count: u64,
59}
60
61impl TransferStats {
62    /// Format fast-path counts as e.g. "1 directory", "3 files", "1 directory + 3 files".
63    /// Returns `None` when both counts are zero.
64    #[must_use]
65    fn fast_path_summary(self) -> Option<String> {
66        if self.fast_path_file_count == 0 && self.fast_path_dir_count == 0 {
67            return None;
68        }
69        let mut parts = Vec::new();
70        if self.fast_path_dir_count > 0 {
71            let noun = if self.fast_path_dir_count == 1 {
72                "directory"
73            } else {
74                "directories"
75            };
76            parts.push(format!("{} {noun}", self.fast_path_dir_count));
77        }
78        if self.fast_path_file_count > 0 {
79            let noun = if self.fast_path_file_count == 1 {
80                "file"
81            } else {
82                "files"
83            };
84            parts.push(format!("{} {noun}", self.fast_path_file_count));
85        }
86        Some(parts.join(" + "))
87    }
88}
89
90impl std::ops::AddAssign for TransferStats {
91    fn add_assign(&mut self, rhs: Self) {
92        self.io_bytes += rhs.io_bytes;
93        self.fast_path_file_count += rhs.fast_path_file_count;
94        self.fast_path_dir_count += rhs.fast_path_dir_count;
95    }
96}
97
98pub struct Ctx<'a> {
99    pub moc: MoveOrCopy,
100    pub force: bool,
101    pub dry_run: bool,
102    pub batch_size: usize,
103    pub mp: &'a indicatif::MultiProgress,
104    pub ctrlc: &'a AtomicBool,
105}
106
107impl Ctx<'_> {
108    /// Dim the detail text when in a batch (batch summary is the primary output).
109    #[must_use]
110    pub fn maybe_dim(&self, s: String) -> String {
111        if self.batch_size > 1 {
112            s.dimmed().to_string()
113        } else {
114            s
115        }
116    }
117
118    /// Format a completion message for a file or directory operation.
119    #[must_use]
120    pub(crate) fn done_message<Src: AsRef<Path>, Dest: AsRef<Path>>(
121        &self,
122        kind: SourceKind,
123        stats: TransferStats,
124        elapsed: std::time::Duration,
125        src: Src,
126        dest: Dest,
127    ) -> String {
128        let detail = format!(
129            "{}: {}",
130            self.done_stats(kind, stats, elapsed),
131            message_with_arrow(src, dest, self.moc, true),
132        );
133        format!("{} {}", kind.done_arrow(), self.maybe_dim(detail))
134    }
135
136    #[must_use]
137    fn done_stats(
138        &self,
139        kind: SourceKind,
140        stats: TransferStats,
141        elapsed: std::time::Duration,
142    ) -> String {
143        let verb = match (self.moc, kind) {
144            (MoveOrCopy::Move, SourceKind::File) => "Moved",
145            (MoveOrCopy::Move, SourceKind::Dir) => "Merged",
146            (MoveOrCopy::Copy, _) => "Copied",
147        };
148
149        let fast_parts = stats.fast_path_summary();
150
151        let duration = if elapsed.as_secs() > 0 {
152            format!(" in {}", indicatif::HumanDuration(elapsed))
153        } else {
154            String::new()
155        };
156
157        if stats.io_bytes > 0 {
158            let fast_suffix = if let Some(summary) = &fast_parts {
159                let label = match self.moc {
160                    MoveOrCopy::Move => "renamed",
161                    MoveOrCopy::Copy => "reflinked",
162                };
163                format!(", {summary} {label}")
164            } else {
165                String::new()
166            };
167            format!(
168                "{verb} {}{duration}{}{fast_suffix}",
169                indicatif::HumanBytes(stats.io_bytes),
170                human_speed(stats.io_bytes, elapsed),
171            )
172        } else if let Some(summary) = &fast_parts {
173            let label = match self.moc {
174                MoveOrCopy::Move => "Renamed",
175                MoveOrCopy::Copy => "Reflinked",
176            };
177            format!("{label} {summary}{duration}")
178        } else {
179            format!("{verb}{duration}")
180        }
181    }
182}
183
184#[must_use]
185pub fn init_logging(level_filter: LevelFilter) -> indicatif::MultiProgress {
186    let mp = indicatif::MultiProgress::new();
187    if level_filter < LevelFilter::Info {
188        mp.set_draw_target(indicatif::ProgressDrawTarget::hidden());
189    }
190    let mp_clone = mp.clone();
191
192    env_logger::Builder::new()
193        .filter_level(level_filter)
194        .format(move |buf, record| {
195            let ts = chrono::Local::now().to_rfc3339().bold();
196
197            let file_and_line = format!(
198                "[{}:{}]",
199                record
200                    .file()
201                    .map(Path::new)
202                    .and_then(Path::file_name)
203                    .unwrap_or_default()
204                    .display(),
205                record.line().unwrap_or(0),
206            )
207            .italic();
208            let level = match record.level() {
209                log::Level::Error => "ERROR".red(),
210                log::Level::Warn => "WARN ".yellow(),
211                log::Level::Info => "INFO ".green(),
212                log::Level::Debug => "DEBUG".blue(),
213                log::Level::Trace => "TRACE".magenta(),
214            }
215            .bold();
216
217            let msg = format!("{ts} {file_and_line:12} {level} {}", record.args());
218            if mp_clone.is_hidden() {
219                writeln!(buf, "{msg}")
220            } else {
221                mp_clone.println(msg)
222            }
223        })
224        .init();
225
226    mp
227}
228
229fn validate_sources(srcs: &[&Path], dest: &Path) -> anyhow::Result<SourceKind> {
230    let mut all_files = true;
231    let mut all_dirs = true;
232    for src in srcs {
233        if src.is_file() {
234            all_dirs = false;
235        } else if src.is_dir() {
236            all_files = false;
237        } else {
238            bail!(
239                "Source path '{}' is neither a file nor directory.",
240                src.display()
241            );
242        }
243    }
244
245    if srcs.len() > 1 {
246        ensure!(
247            all_files || all_dirs,
248            "When there are multiple sources, they must be all files or all directories.",
249        );
250        if !dest.is_dir() {
251            if all_dirs || dest.to_string_lossy().ends_with('/') {
252                std::fs::create_dir_all(dest)?;
253            } else {
254                bail!(
255                    "When there are multiple file sources, the destination must be a directory or end with '/'."
256                );
257            }
258        }
259    }
260
261    Ok(if all_files {
262        SourceKind::File
263    } else {
264        SourceKind::Dir
265    })
266}
267
268fn process_source(
269    src: &Path,
270    dest: &Path,
271    batch_pb: &indicatif::ProgressBar,
272    base: u64,
273    sized: bool,
274    ctx: &Ctx,
275) -> anyhow::Result<(String, TransferStats)> {
276    let progress = move |bytes: u64| {
277        if sized {
278            batch_pb.set_position(base + bytes);
279        }
280    };
281    if src.is_file() {
282        file::move_or_copy(src, dest, progress, ctx)
283    } else {
284        dir::merge_or_copy(src, dest, progress, ctx)
285    }
286}
287
288/// # Errors
289///
290/// Will return `Err` if move/merge fails for any reason.
291pub fn run_batch<Src: AsRef<Path>, Srcs: AsRef<[Src]>, Dest: AsRef<Path>>(
292    srcs: Srcs,
293    dest: Dest,
294    ctx: &Ctx,
295) -> anyhow::Result<String> {
296    let srcs = srcs
297        .as_ref()
298        .iter()
299        .map(std::convert::AsRef::as_ref)
300        .collect::<Vec<_>>();
301    let dest = dest.as_ref();
302    log::trace!(
303        "run_batch('{:?}', '{}', {:?})",
304        srcs.iter().map(|s| s.display()).collect::<Vec<_>>(),
305        dest.display(),
306        ctx.moc,
307    );
308
309    let kind = validate_sources(&srcs, dest)?;
310
311    if ctx.dry_run {
312        for src in srcs {
313            let action = match (ctx.moc, src.is_dir()) {
314                (MoveOrCopy::Move, true) => "merge",
315                (MoveOrCopy::Move, false) => "move",
316                (MoveOrCopy::Copy, _) => "copy",
317            };
318            println!("Would {action} '{}' to '{}'", src.display(), dest.display());
319        }
320        return Ok(String::new());
321    }
322
323    let n = srcs.len();
324    let sizes: Vec<u64> = srcs
325        .iter()
326        .map(|s| {
327            let skip =
328                matches!(ctx.moc, MoveOrCopy::Move) && s.is_dir() && dir::same_device(s, dest);
329            if skip { 0 } else { source_size(s) }
330        })
331        .collect();
332    let batch_pb = if n > 1 {
333        ctx.mp
334            .add(bytes_progress_bar(sizes.iter().sum(), "blue", ctx.moc))
335    } else {
336        indicatif::ProgressBar::hidden()
337    };
338
339    let batch_timer = std::time::Instant::now();
340    let mut cumulative: u64 = 0;
341    let mut batch_stats = TransferStats::default();
342    for (i, src) in srcs.iter().enumerate() {
343        if ctx.ctrlc.load(Ordering::Relaxed) {
344            log::error!(
345                "{FAIL_MARK} Cancelled: {}",
346                message_with_arrow(src, dest, ctx.moc, true)
347            );
348            std::process::exit(130);
349        }
350
351        let up_next = srcs
352            .get(i + 1)
353            .map(|s| {
354                format!(
355                    " Up Next: {}",
356                    s.file_name().unwrap_or(s.as_os_str()).to_string_lossy()
357                )
358                .dimmed()
359            })
360            .unwrap_or_default();
361        batch_pb.set_message(format!("[{}/{}]{up_next}", i + 1, n));
362
363        let (msg, stats) = process_source(src, dest, &batch_pb, cumulative, sizes[i] > 0, ctx)
364            .with_context(|| message_with_arrow(src, dest, ctx.moc, false))?;
365        batch_stats += stats;
366
367        cumulative += sizes[i];
368        batch_pb.set_position(cumulative);
369        ctx.mp.println(msg)?;
370    }
371    batch_pb.finish_and_clear();
372
373    batch_pb.println(format!(
374        "{} {}",
375        kind.done_arrow(),
376        ctx.done_stats(kind, batch_stats, batch_timer.elapsed()),
377    ));
378
379    Ok(String::new())
380}
381
382/// # Errors
383///
384/// Will return `Err` if can not register Ctrl-C handler.
385pub fn ctrlc_flag() -> anyhow::Result<Arc<AtomicBool>> {
386    let flag = Arc::new(AtomicBool::new(false));
387    let flag_clone = Arc::clone(&flag);
388    let already_pressed = AtomicBool::new(false);
389    ctrlc::set_handler(move || {
390        if already_pressed.swap(true, Ordering::Relaxed) {
391            log::warn!("{FAIL_MARK} Ctrl-C again, force exiting...");
392            // Use _exit() to terminate immediately without running atexit handlers
393            // or destructors, which can deadlock (e.g. indicatif's render thread).
394            unsafe { libc::_exit(130) };
395        }
396        log::warn!(
397            "{FAIL_MARK} Ctrl-C detected, finishing current file... (press again to force exit)"
398        );
399        flag_clone.store(true, Ordering::Relaxed);
400    })?;
401
402    Ok(flag)
403}
404
405fn bytes_progress_bar(size: u64, color: &str, moc: MoveOrCopy) -> indicatif::ProgressBar {
406    let template = format!(
407        "{{total_bytes:>11}} [{{bar:40.{color}/white}}] {{bytes:<11}} ({{bytes_per_sec:>13}}, ETA: {{eta_precise}} ) {{prefix}} {{wide_msg}}"
408    );
409    let style = indicatif::ProgressStyle::with_template(&template)
410        .unwrap()
411        .progress_chars(moc.progress_chars());
412    indicatif::ProgressBar::new(size).with_style(style)
413}
414
415fn item_progress_bar<Src: AsRef<Path>, Dest: AsRef<Path>>(
416    size: u64,
417    src: Src,
418    dest: Dest,
419    moc: MoveOrCopy,
420) -> indicatif::ProgressBar {
421    let color = if src.as_ref().is_dir() {
422        "cyan"
423    } else {
424        "green"
425    };
426    bytes_progress_bar(size, color, moc).with_message(message_with_arrow(src, dest, moc, true))
427}
428
429fn source_size(src: &Path) -> u64 {
430    if src.is_file() {
431        std::fs::metadata(src).map(|m| m.len()).unwrap_or(0)
432    } else {
433        dir::collect_total_size(src)
434    }
435}
436
437pub const FAIL_MARK: &str = "✗";
438
439pub(crate) fn human_speed(bytes: u64, elapsed: std::time::Duration) -> String {
440    let millis = elapsed.as_millis();
441    if millis == 0 {
442        return String::new();
443    }
444    let bps = u64::try_from(u128::from(bytes) * 1000 / millis).unwrap_or(u64::MAX);
445    format!(" ({}/s)", indicatif::HumanBytes(bps))
446}
447
448fn message_with_arrow<Src: AsRef<Path>, Dest: AsRef<Path>>(
449    src: Src,
450    dest: Dest,
451    moc: MoveOrCopy,
452    styled: bool,
453) -> String {
454    let (src, dest) = (src.as_ref(), dest.as_ref());
455    let arrow = moc.arrow();
456
457    let src_parts: Vec<_> = src.components().collect();
458    let dest_parts: Vec<_> = dest.components().collect();
459
460    let mut prefix_len = src_parts
461        .iter()
462        .zip(dest_parts.iter())
463        .take_while(|(a, b)| a == b)
464        .count();
465    // Don't factor out a root-only prefix — absorb "/" into the diffs
466    if prefix_len == 1
467        && matches!(
468            src_parts[0],
469            std::path::Component::RootDir | std::path::Component::Prefix(_)
470        )
471    {
472        prefix_len = 0;
473    }
474
475    let src_rest = &src_parts[prefix_len..];
476    let dest_rest = &dest_parts[prefix_len..];
477
478    // Find common suffix. An empty diff on one side is rendered as "."
479    // so the suffix still factors when one path is a sub-path of the other.
480    let suffix_len = src_rest
481        .iter()
482        .rev()
483        .zip(dest_rest.iter().rev())
484        .take_while(|(a, b)| a == b)
485        .count();
486
487    if prefix_len == 0 && suffix_len == 0 {
488        return format!("{} {arrow} {}", src.display(), dest.display());
489    }
490
491    let prefix: PathBuf = src_parts[..prefix_len].iter().collect();
492    let src_diff: PathBuf = src_parts[prefix_len..src_parts.len() - suffix_len]
493        .iter()
494        .collect();
495    let dest_diff: PathBuf = dest_parts[prefix_len..dest_parts.len() - suffix_len]
496        .iter()
497        .collect();
498    let suffix: PathBuf = src_parts[src_parts.len() - suffix_len..].iter().collect();
499
500    let path_or_dot = |p: &Path| {
501        if p.as_os_str().is_empty() {
502            ".".to_string()
503        } else {
504            p.display().to_string()
505        }
506    };
507    let src_diff_str = path_or_dot(&src_diff);
508    let dest_diff_str = path_or_dot(&dest_diff);
509
510    let prefix_str = prefix.display().to_string();
511    let sep = if prefix_str.ends_with('/') { "" } else { "/" };
512
513    if styled {
514        let dim = |s: &str| s.dimmed().to_string();
515        let diff = format!(
516            "{} {} {arrow} {} {}",
517            dim("{"),
518            src_diff_str.magenta(),
519            dest_diff_str.green(),
520            dim("}")
521        );
522        let italic = |s: &str| s.italic().to_string();
523        match (prefix_len > 0, suffix_len > 0) {
524            (true, true) => {
525                format!(
526                    "{}{}{diff}{}",
527                    dim(&prefix_str),
528                    dim(sep),
529                    italic(&format!("/{}", suffix.display()))
530                )
531            }
532            (true, false) => format!("{}{}{diff}", dim(&prefix_str), dim(sep)),
533            (false, true) => format!("{diff}{}", italic(&format!("/{}", suffix.display()))),
534            (false, false) => unreachable!(),
535        }
536    } else {
537        let diff = format!("{{ {src_diff_str} {arrow} {dest_diff_str} }}");
538        match (prefix_len > 0, suffix_len > 0) {
539            (true, true) => format!("{prefix_str}{sep}{diff}/{}", suffix.display()),
540            (true, false) => format!("{prefix_str}{sep}{diff}"),
541            (false, true) => format!("{diff}/{}", suffix.display()),
542            (false, false) => unreachable!(),
543        }
544    }
545}
546
547#[cfg(test)]
548pub(crate) mod tests {
549    use super::*;
550    use std::fs;
551    use std::path::{Path, PathBuf};
552    use tempfile::tempdir;
553
554    pub(crate) fn noop_ctrlc() -> Arc<AtomicBool> {
555        Arc::new(AtomicBool::new(false))
556    }
557
558    pub(crate) fn hidden_multi_progress() -> indicatif::MultiProgress {
559        indicatif::MultiProgress::with_draw_target(indicatif::ProgressDrawTarget::hidden())
560    }
561
562    pub(crate) fn create_temp_file<P: AsRef<Path>>(dir: P, name: &str, content: &str) -> PathBuf {
563        let path = dir.as_ref().join(name);
564        if let Some(parent) = path.parent() {
565            fs::create_dir_all(parent).unwrap();
566        }
567        std::fs::write(&path, content).unwrap();
568        path
569    }
570
571    pub(crate) fn assert_file_moved<Src: AsRef<Path>, Dest: AsRef<Path>>(
572        src_path: Src,
573        dest_path: Dest,
574        expected_content: &str,
575    ) {
576        let src = src_path.as_ref();
577        let dest = dest_path.as_ref();
578        assert!(
579            !src.exists(),
580            "Source file still exists at {}",
581            src.display()
582        );
583        assert!(
584            dest.exists(),
585            "Destination file does not exist at {}",
586            dest.display()
587        );
588        let moved_content = fs::read_to_string(dest_path).unwrap();
589        assert_eq!(
590            moved_content, expected_content,
591            "File content doesn't match after move"
592        );
593    }
594
595    pub(crate) fn assert_file_not_moved<Src: AsRef<Path>, Dest: AsRef<Path>>(
596        src_path: Src,
597        dest_path: Dest,
598    ) {
599        let src = src_path.as_ref();
600        let dest = dest_path.as_ref();
601        assert!(
602            src.exists(),
603            "Source file does not exist at {}",
604            src.display()
605        );
606        assert!(
607            !dest.exists(),
608            "Destination file should not exist at {}",
609            dest.display()
610        );
611    }
612
613    pub(crate) fn assert_file_copied<Src: AsRef<Path>, Dest: AsRef<Path>>(
614        src_path: Src,
615        dest_path: Dest,
616    ) {
617        let src = src_path.as_ref();
618        let dest = dest_path.as_ref();
619        assert!(
620            src.exists(),
621            "Source file does not exists at {}",
622            src.display()
623        );
624        assert!(
625            dest.exists(),
626            "Destination file does not exist at {}",
627            dest.display()
628        );
629        assert_eq!(
630            fs::read_to_string(src).unwrap(),
631            fs::read_to_string(dest_path).unwrap(),
632            "File content doesn't match after copy"
633        );
634    }
635
636    pub(crate) fn assert_error_with_msg(result: anyhow::Result<String>, msg: &str) {
637        assert!(result.is_err(), "Expected an error, but got success");
638        let err_msg = format!("{:#}", result.unwrap_err());
639        assert!(
640            err_msg.contains(msg),
641            "Error message doesn't contain '{msg}': {err_msg}",
642        );
643    }
644
645    fn _run_batch<Src: AsRef<Path>, Srcs: AsRef<[Src]>, Dest: AsRef<Path>>(
646        srcs: Srcs,
647        dest: Dest,
648        moc: MoveOrCopy,
649        force: bool,
650    ) -> anyhow::Result<String> {
651        let mp = hidden_multi_progress();
652        let ctrlc = noop_ctrlc();
653        let ctx = Ctx {
654            moc,
655            force,
656            dry_run: false,
657            batch_size: srcs.as_ref().len(),
658            mp: &mp,
659            ctrlc: &ctrlc,
660        };
661        run_batch(srcs, dest, &ctx)
662    }
663
664    #[test]
665    fn move_file_to_new_dest() {
666        let work_dir = tempdir().unwrap();
667        let src_content = "This is a test file";
668        let src_path = create_temp_file(work_dir.path(), "a", src_content);
669        let dest_path = work_dir.path().join("b");
670
671        _run_batch([&src_path], &dest_path, MoveOrCopy::Move, false).unwrap();
672        assert_file_moved(&src_path, &dest_path, src_content);
673    }
674
675    #[test]
676    fn move_multiple_files_to_directory() {
677        let work_dir = tempdir().unwrap();
678        let src_content = "This is a test file";
679        let src_paths = vec![
680            create_temp_file(work_dir.path(), "a", src_content),
681            create_temp_file(work_dir.path(), "b", src_content),
682        ];
683        let dest_dir = work_dir.path().join("dest");
684        fs::create_dir_all(&dest_dir).unwrap();
685
686        _run_batch(&src_paths, &dest_dir, MoveOrCopy::Move, false).unwrap();
687        for src_path in src_paths {
688            let dest_path = dest_dir.join(src_path.file_name().unwrap());
689            assert_file_moved(&src_path, &dest_path, src_content);
690        }
691    }
692
693    #[test]
694    fn move_multiple_files_fails_when_dest_does_not_exist_without_trailing_slash() {
695        let work_dir = tempdir().unwrap();
696        let src_content = "This is a test file";
697        let src_paths = vec![
698            create_temp_file(work_dir.path(), "a", src_content),
699            create_temp_file(work_dir.path(), "b", src_content),
700        ];
701        let dest_dir = work_dir.path().join("dest");
702
703        assert_error_with_msg(
704            _run_batch(&src_paths, &dest_dir, MoveOrCopy::Move, false),
705            "destination must be a directory or end with '/'",
706        );
707        for src_path in src_paths {
708            let dest_path = dest_dir.join(src_path.file_name().unwrap());
709            assert_file_not_moved(&src_path, &dest_path);
710        }
711    }
712
713    #[test]
714    fn move_multiple_files_creates_dest_with_trailing_slash() {
715        let work_dir = tempdir().unwrap();
716        let src_content = "This is a test file";
717        let src_paths = vec![
718            create_temp_file(work_dir.path(), "a", src_content),
719            create_temp_file(work_dir.path(), "b", src_content),
720        ];
721        let dest_dir = work_dir.path().join("dest/");
722
723        _run_batch(&src_paths, &dest_dir, MoveOrCopy::Move, false).unwrap();
724        for src_path in &src_paths {
725            let dest_path = dest_dir.join(src_path.file_name().unwrap());
726            assert_file_moved(src_path, &dest_path, src_content);
727        }
728    }
729
730    #[test]
731    fn move_multiple_dirs_creates_dest_when_it_does_not_exist() {
732        let work_dir = tempdir().unwrap();
733        let src_dirs: Vec<_> = (0..3)
734            .map(|i| {
735                let d = tempdir().unwrap();
736                create_temp_file(d.path(), &format!("file{i}"), &format!("content{i}"));
737                d
738            })
739            .collect();
740        let dest_dir = work_dir.path().join("dest");
741
742        _run_batch(&src_dirs, &dest_dir, MoveOrCopy::Move, false).unwrap();
743        for (i, src_dir) in src_dirs.iter().enumerate() {
744            let src_path = src_dir.path().join(format!("file{i}"));
745            let dest_path = dest_dir.join(format!("file{i}"));
746            assert_file_moved(&src_path, &dest_path, &format!("content{i}"));
747        }
748    }
749
750    #[test]
751    fn move_mix_of_files_and_directories_fails() {
752        let work_dir = tempdir().unwrap();
753        let src_dir = tempdir().unwrap();
754        let src_paths = vec![
755            create_temp_file(work_dir.path(), "a", "This is a test file"),
756            src_dir.path().to_path_buf(),
757        ];
758        let dest_dir = work_dir.path().join("dest");
759        fs::create_dir_all(&dest_dir).unwrap();
760
761        assert_error_with_msg(
762            _run_batch(&src_paths, &dest_dir, MoveOrCopy::Move, false),
763            "When there are multiple sources, they must be all files or all directories.",
764        );
765    }
766
767    #[test]
768    fn copy_file_basic() {
769        let work_dir = tempdir().unwrap();
770        let src_content = "This is a test file";
771        let src_path = create_temp_file(work_dir.path(), "a", src_content);
772        let dest_path = work_dir.path().join("b");
773
774        _run_batch([&src_path], &dest_path, MoveOrCopy::Copy, false).unwrap();
775        assert_file_copied(&src_path, &dest_path);
776    }
777
778    #[test]
779    fn move_file_into_directory_with_trailing_slash() {
780        let work_dir = tempdir().unwrap();
781        let src_content = "This is a test file";
782        let src_name = "a";
783        let src_path = create_temp_file(&work_dir, src_name, src_content);
784        let dest_dir = work_dir.path().join("b/c/");
785
786        _run_batch([&src_path], &dest_dir, MoveOrCopy::Move, false).unwrap();
787        assert_file_moved(src_path, dest_dir.join(src_name), src_content);
788    }
789
790    #[test]
791    fn copy_file_into_directory_with_trailing_slash() {
792        let work_dir = tempdir().unwrap();
793        let src_content = "This is a test file";
794        let src_name = "a";
795        let src_path = create_temp_file(&work_dir, src_name, src_content);
796        let dest_dir = work_dir.path().join("b/c/");
797
798        _run_batch([&src_path], &dest_dir, MoveOrCopy::Copy, false).unwrap();
799        assert_file_copied(src_path, dest_dir.join(src_name));
800    }
801
802    #[test]
803    fn merge_directory_into_empty_dest() {
804        let src_dir = tempdir().unwrap();
805        let src_rel_paths = [
806            "file1",
807            "file2",
808            "subdir/subfile1",
809            "subdir/subfile2",
810            "subdir/nested/nested_file",
811        ];
812        for path in src_rel_paths {
813            create_temp_file(src_dir.path(), path, &format!("From source: {path}"));
814        }
815
816        let dest_dir = tempdir().unwrap();
817        _run_batch([&src_dir], &dest_dir, MoveOrCopy::Move, false).unwrap();
818        for path in src_rel_paths {
819            let src_path = src_dir.path().join(path);
820            let dest_path = dest_dir.path().join(path);
821            assert_file_moved(&src_path, &dest_path, &format!("From source: {path}"));
822        }
823    }
824
825    #[test]
826    fn merge_multiple_directories_into_dest() {
827        let src_num = 5;
828        let src_dirs = (0..src_num)
829            .filter_map(|_| tempdir().ok())
830            .collect::<Vec<tempfile::TempDir>>();
831        let src_rel_paths = (0..src_num)
832            .map(|i| format! {"nested{i}/file{i}"})
833            .collect::<Vec<String>>();
834        (0..src_num).for_each(|i| {
835            create_temp_file(&src_dirs[i], &src_rel_paths[i], &format!("content{i}"));
836        });
837
838        let dest_dir = tempdir().unwrap();
839        _run_batch(&src_dirs, &dest_dir, MoveOrCopy::Move, false).unwrap();
840        (0..src_num).for_each(|i| {
841            let src_path = src_dirs[i].path().join(&src_rel_paths[i]);
842            let dest_path = dest_dir.path().join(&src_rel_paths[i]);
843            assert_file_moved(&src_path, &dest_path, &format!("content{i}"));
844        });
845    }
846
847    #[test]
848    fn dry_run_does_not_modify_files() {
849        let work_dir = tempdir().unwrap();
850        let src_content = "This is a test file";
851        let src_path = create_temp_file(work_dir.path(), "a", src_content);
852        let dest_path = work_dir.path().join("b");
853
854        let mp = hidden_multi_progress();
855        let ctrlc = noop_ctrlc();
856        let ctx = Ctx {
857            moc: MoveOrCopy::Move,
858            force: false,
859            dry_run: true,
860            batch_size: 1,
861            mp: &mp,
862            ctrlc: &ctrlc,
863        };
864        run_batch([&src_path], &dest_path, &ctx).unwrap();
865
866        assert!(
867            src_path.exists(),
868            "Source should still exist in dry-run mode"
869        );
870        assert!(
871            !dest_path.exists(),
872            "Dest should not be created in dry-run mode"
873        );
874    }
875
876    #[test]
877    fn fails_with_nonexistent_source() {
878        let work_dir = tempdir().unwrap();
879        let src_path = work_dir.path().join("nonexistent");
880        let dest_path = work_dir.path().join("dest");
881
882        assert_error_with_msg(
883            _run_batch([&src_path], &dest_path, MoveOrCopy::Move, false),
884            "neither a file nor directory",
885        );
886    }
887
888    #[test]
889    fn copy_multiple_files_to_directory() {
890        let work_dir = tempdir().unwrap();
891        let src_paths = vec![
892            create_temp_file(work_dir.path(), "a", "content_a"),
893            create_temp_file(work_dir.path(), "b", "content_b"),
894        ];
895        let dest_dir = work_dir.path().join("dest");
896        fs::create_dir_all(&dest_dir).unwrap();
897
898        _run_batch(&src_paths, &dest_dir, MoveOrCopy::Copy, false).unwrap();
899        for src_path in &src_paths {
900            let dest_path = dest_dir.join(src_path.file_name().unwrap());
901            assert_file_copied(src_path, &dest_path);
902        }
903    }
904
905    #[test]
906    fn copy_directory_into_empty_dest() {
907        let src_dir = tempdir().unwrap();
908        let src_rel_paths = [
909            "file1",
910            "file2",
911            "subdir/subfile1",
912            "subdir/subfile2",
913            "subdir/nested/nested_file",
914        ];
915        for path in src_rel_paths {
916            create_temp_file(src_dir.path(), path, &format!("From source: {path}"));
917        }
918
919        let dest_dir = tempdir().unwrap();
920        _run_batch([&src_dir], &dest_dir, MoveOrCopy::Copy, false).unwrap();
921        for path in src_rel_paths {
922            let src_path = src_dir.path().join(path);
923            let dest_path = dest_dir.path().join(path);
924            assert_file_copied(&src_path, &dest_path);
925        }
926    }
927
928    #[test]
929    fn message_with_arrow_common_prefix_only() {
930        assert_eq!(
931            message_with_arrow("/a/b/c/d", "/a/b/x/y", MoveOrCopy::Move, false),
932            "/a/b/{ c/d -> x/y }"
933        );
934    }
935
936    #[test]
937    fn message_with_arrow_common_prefix_and_suffix() {
938        assert_eq!(
939            message_with_arrow(
940                "/a/b/c/file.txt",
941                "/a/x/y/file.txt",
942                MoveOrCopy::Move,
943                false
944            ),
945            "/a/{ b/c -> x/y }/file.txt"
946        );
947    }
948
949    #[test]
950    fn message_with_arrow_dest_diff_empty_uses_dot() {
951        assert_eq!(
952            message_with_arrow(
953                "/Users/junz/subtitled/todo/.organized",
954                "/Users/junz/subtitled/.organized",
955                MoveOrCopy::Move,
956                false,
957            ),
958            "/Users/junz/subtitled/{ todo -> . }/.organized"
959        );
960    }
961
962    #[test]
963    fn message_with_arrow_src_is_suffix_of_dest() {
964        assert_eq!(
965            message_with_arrow("GC", "/Volumes/hdd/GC", MoveOrCopy::Move, false),
966            "{ . -> /Volumes/hdd }/GC"
967        );
968    }
969
970    #[test]
971    fn message_with_arrow_dest_under_src() {
972        assert_eq!(
973            message_with_arrow("/a/b", "/a/b/c", MoveOrCopy::Move, false),
974            "/a/b/{ . -> c }"
975        );
976    }
977
978    #[test]
979    fn message_with_arrow_dest_extends_src() {
980        // src is an ancestor of dest: prefix factors fully, dest has a tail.
981        assert_eq!(
982            message_with_arrow("/a", "/a/b", MoveOrCopy::Move, false),
983            "/a/{ . -> b }"
984        );
985    }
986
987    #[test]
988    fn message_with_arrow_src_named_like_dest_tail() {
989        // Only root is shared as prefix (reset to none); the basename factors as suffix.
990        assert_eq!(
991            message_with_arrow("/b", "/a/b", MoveOrCopy::Move, false),
992            "{ / -> /a }/b"
993        );
994    }
995
996    #[test]
997    fn message_with_arrow_no_common_relative() {
998        assert_eq!(
999            message_with_arrow("foo/bar", "baz/qux", MoveOrCopy::Move, false),
1000            "foo/bar -> baz/qux"
1001        );
1002    }
1003
1004    #[test]
1005    fn message_with_arrow_only_root_common() {
1006        assert_eq!(
1007            message_with_arrow("/foo/bar", "/baz/qux", MoveOrCopy::Move, false),
1008            "/foo/bar -> /baz/qux"
1009        );
1010    }
1011
1012    #[test]
1013    fn message_with_arrow_copy() {
1014        assert_eq!(
1015            message_with_arrow("/a/b/src.txt", "/a/c/src.txt", MoveOrCopy::Copy, false),
1016            "/a/{ b => c }/src.txt"
1017        );
1018    }
1019
1020    #[test]
1021    fn message_with_arrow_suffix_only() {
1022        assert_eq!(
1023            message_with_arrow("foo/common.txt", "bar/common.txt", MoveOrCopy::Move, false),
1024            "{ foo -> bar }/common.txt"
1025        );
1026    }
1027}