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 #[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 #[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 #[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
288pub 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
382pub 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 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 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 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 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 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}