1use anyhow::{Context, Result};
2use async_recursion::async_recursion;
3use enum_map::{Enum, EnumMap};
4use tokio::io::AsyncWriteExt;
5use tracing::instrument;
6
7use crate::copy::is_file_type_same;
8use crate::filecmp;
9use crate::progress;
10
11#[derive(Copy, Clone, Debug, Enum)]
12pub enum CompareResult {
13 Same,
14 Different,
15 SrcMissing, DstMissing, }
18
19#[derive(Copy, Clone, Debug, Enum)]
20pub enum ObjType {
21 File,
22 Dir,
23 Symlink,
24 Other, }
26
27pub type ObjSettings = EnumMap<ObjType, filecmp::MetadataCmpSettings>;
28
29#[derive(Debug, Clone)]
30pub struct Settings {
31 pub compare: ObjSettings,
32 pub fail_early: bool,
33 pub exit_early: bool,
34 pub expand_missing: bool,
35 pub filter: Option<crate::filter::FilterSettings>,
36}
37
38pub type Mismatch = EnumMap<ObjType, EnumMap<CompareResult, u64>>;
39
40pub type Skipped = EnumMap<ObjType, u64>;
42
43#[derive(Copy, Clone, Debug, Default, clap::ValueEnum)]
45pub enum OutputFormat {
46 #[default]
48 Json,
49 Text,
51}
52
53fn compare_result_name(cr: CompareResult) -> &'static str {
54 match cr {
55 CompareResult::Same => "same",
56 CompareResult::Different => "different",
57 CompareResult::SrcMissing => "src_missing",
58 CompareResult::DstMissing => "dst_missing",
59 }
60}
61
62fn obj_type_name(ot: ObjType) -> &'static str {
63 match ot {
64 ObjType::File => "file",
65 ObjType::Dir => "dir",
66 ObjType::Symlink => "symlink",
67 ObjType::Other => "other",
68 }
69}
70
71fn path_to_json_string(path: &std::path::Path) -> String {
77 use std::os::unix::ffi::OsStrExt;
78 let bytes = path.as_os_str().as_bytes();
79 let mut out = String::with_capacity(bytes.len());
80 for chunk in bytes.utf8_chunks() {
81 for c in chunk.valid().chars() {
82 if c == '\\' {
83 out.push_str("\\\\");
84 } else {
85 out.push(c);
86 }
87 }
88 for &b in chunk.invalid() {
89 use std::fmt::Write;
90 write!(out, "\\x{b:02x}").unwrap();
91 }
92 }
93 out
94}
95
96#[derive(Default)]
97pub struct Summary {
98 pub mismatch: Mismatch,
99 pub skipped: Skipped,
100 pub src_bytes: u64,
102 pub dst_bytes: u64,
104}
105
106impl std::ops::Add for Summary {
107 type Output = Self;
108 fn add(self, other: Self) -> Self {
109 let mut mismatch = self.mismatch;
110 for (obj_type, &cmp_res_map) in &other.mismatch {
111 for (cmp_res, &count) in &cmp_res_map {
112 mismatch[obj_type][cmp_res] += count;
113 }
114 }
115 let mut skipped = self.skipped;
116 for (obj_type, &count) in &other.skipped {
117 skipped[obj_type] += count;
118 }
119 Self {
120 mismatch,
121 skipped,
122 src_bytes: self.src_bytes + other.src_bytes,
123 dst_bytes: self.dst_bytes + other.dst_bytes,
124 }
125 }
126}
127
128impl std::fmt::Display for Summary {
129 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
130 writeln!(
131 f,
132 "src size (compared): {}",
133 bytesize::ByteSize(self.src_bytes)
134 )?;
135 writeln!(
136 f,
137 "dst size (compared): {}",
138 bytesize::ByteSize(self.dst_bytes)
139 )?;
140 for (obj_type, &cmp_res_map) in &self.mismatch {
141 for (cmp_res, &count) in &cmp_res_map {
142 writeln!(f, "{obj_type:?} {cmp_res:?}: {count}")?;
143 }
144 }
145 for (obj_type, &count) in &self.skipped {
146 if count > 0 {
147 writeln!(f, "{obj_type:?} Skipped: {count}")?;
148 }
149 }
150 Ok(())
151 }
152}
153
154pub struct FormattedSummary {
157 pub summary: Summary,
158 pub format: OutputFormat,
159}
160
161impl std::fmt::Display for FormattedSummary {
162 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
163 match self.format {
164 OutputFormat::Text => write!(f, "{}", self.summary),
165 OutputFormat::Json => {
166 let mut mismatch = serde_json::Map::new();
167 for (obj_type, &cmp_res_map) in &self.summary.mismatch {
168 let mut counts = serde_json::Map::new();
169 for (cmp_res, &count) in &cmp_res_map {
170 counts.insert(
171 compare_result_name(cmp_res).to_string(),
172 serde_json::Value::Number(count.into()),
173 );
174 }
175 mismatch.insert(
176 obj_type_name(obj_type).to_string(),
177 serde_json::Value::Object(counts),
178 );
179 }
180 let mut skipped = serde_json::Map::new();
181 for (obj_type, &count) in &self.summary.skipped {
182 if count > 0 {
183 skipped.insert(
184 obj_type_name(obj_type).to_string(),
185 serde_json::Value::Number(count.into()),
186 );
187 }
188 }
189 let stats = crate::collect_runtime_stats();
190 let walltime = crate::get_progress().get_duration();
191 let obj = serde_json::json!({
192 "src_bytes": self.summary.src_bytes,
193 "dst_bytes": self.summary.dst_bytes,
194 "mismatch": serde_json::Value::Object(mismatch),
195 "skipped": serde_json::Value::Object(skipped),
196 "walltime_ms": walltime.as_millis() as u64,
197 "cpu_time_user_ms": stats.cpu_time_user_ms,
198 "cpu_time_kernel_ms": stats.cpu_time_kernel_ms,
199 "peak_rss_bytes": stats.peak_rss_bytes,
200 });
201 write!(f, "{obj}")
202 }
203 }
204 }
205}
206
207#[derive(Clone)]
208pub struct LogWriter {
209 file: Option<std::sync::Arc<tokio::sync::Mutex<tokio::io::BufWriter<tokio::fs::File>>>>,
210 stdout: Option<std::sync::Arc<tokio::sync::Mutex<tokio::io::BufWriter<tokio::io::Stdout>>>>,
211 format: OutputFormat,
212}
213
214impl std::fmt::Debug for LogWriter {
215 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216 f.debug_struct("LogWriter")
217 .field("file", &self.file.is_some())
218 .field("stdout", &self.stdout.is_some())
219 .field("format", &self.format)
220 .finish()
221 }
222}
223
224impl LogWriter {
225 pub async fn new(
231 log_path_opt: Option<&std::path::Path>,
232 use_stdout: bool,
233 format: OutputFormat,
234 ) -> Result<Self> {
235 if let Some(log_path) = log_path_opt {
236 let log_file = tokio::fs::OpenOptions::new()
237 .write(true)
238 .create_new(true)
239 .open(log_path)
240 .await
241 .with_context(|| format!("Failed to open log file: {log_path:?}"))?;
242 let log =
243 std::sync::Arc::new(tokio::sync::Mutex::new(tokio::io::BufWriter::new(log_file)));
244 Ok(Self {
245 file: Some(log),
246 stdout: None,
247 format,
248 })
249 } else if use_stdout {
250 Ok(Self {
251 file: None,
252 stdout: Some(std::sync::Arc::new(tokio::sync::Mutex::new(
253 tokio::io::BufWriter::new(tokio::io::stdout()),
254 ))),
255 format,
256 })
257 } else {
258 Ok(Self {
259 file: None,
260 stdout: None,
261 format,
262 })
263 }
264 }
265 pub async fn silent() -> Result<Self> {
268 Self::new(None, false, OutputFormat::default()).await
269 }
270
271 pub async fn log_mismatch(
272 &self,
273 cmp_result: CompareResult,
274 src_obj_type: Option<ObjType>,
275 src: &std::path::Path,
276 dst_obj_type: Option<ObjType>,
277 dst: &std::path::Path,
278 ) -> Result<()> {
279 let msg = match self.format {
280 OutputFormat::Text => {
281 format!(
282 "[{cmp_result:?}]\n\t[{src_obj_type:?}]\t{src:?}\n\t[{dst_obj_type:?}]\t{dst:?}\n"
283 )
284 }
285 OutputFormat::Json => {
286 let src_type_val = match src_obj_type {
287 Some(ot) => serde_json::Value::String(obj_type_name(ot).to_string()),
288 None => serde_json::Value::Null,
289 };
290 let dst_type_val = match dst_obj_type {
291 Some(ot) => serde_json::Value::String(obj_type_name(ot).to_string()),
292 None => serde_json::Value::Null,
293 };
294 let obj = serde_json::json!({
295 "result": compare_result_name(cmp_result),
296 "src_type": src_type_val,
297 "src": path_to_json_string(src),
298 "dst_type": dst_type_val,
299 "dst": path_to_json_string(dst),
300 });
301 format!("{obj}\n")
302 }
303 };
304 self.write(&msg).await
305 }
306
307 async fn write(&self, msg: &str) -> Result<()> {
308 if let Some(log) = &self.file {
309 let mut log = log.lock().await;
310 log.write_all(msg.as_bytes())
311 .await
312 .context("Failed to write to log file")?;
313 }
314 if let Some(stdout) = &self.stdout {
315 let mut stdout = stdout.lock().await;
316 stdout
317 .write_all(msg.as_bytes())
318 .await
319 .context("Failed to write to stdout")?;
320 }
321 Ok(())
322 }
323
324 pub async fn flush(&self) -> Result<()> {
325 if let Some(log) = &self.file {
326 let mut log = log.lock().await;
327 log.flush().await.context("Failed to flush log file")?;
328 }
329 if let Some(stdout) = &self.stdout {
330 let mut stdout = stdout.lock().await;
331 stdout.flush().await.context("Failed to flush stdout")?;
332 }
333 Ok(())
334 }
335}
336
337fn obj_type(metadata: &std::fs::Metadata) -> ObjType {
338 if metadata.is_file() {
339 ObjType::File
340 } else if metadata.is_dir() {
341 ObjType::Dir
342 } else if metadata.is_symlink() {
343 ObjType::Symlink
344 } else {
345 ObjType::Other
347 }
348}
349
350#[instrument(skip(prog_track))]
353pub async fn cmp(
354 prog_track: &'static progress::Progress,
355 src: &std::path::Path,
356 dst: &std::path::Path,
357 log: &LogWriter,
358 settings: &Settings,
359) -> Result<Summary> {
360 cmp_internal(prog_track, src, dst, src, dst, log, settings).await
361}
362
363#[instrument(skip(prog_track))]
366#[async_recursion]
367async fn expand_missing_tree(
368 prog_track: &'static progress::Progress,
369 existing_path: &std::path::Path,
370 mirror_path: &std::path::Path,
371 existing_root: &std::path::Path,
372 result: CompareResult,
373 log: &LogWriter,
374 settings: &Settings,
375) -> Result<Summary> {
376 let _prog_guard = prog_track.ops.guard();
377 let side = match result {
383 CompareResult::DstMissing => congestion::Side::Source,
384 CompareResult::SrcMissing => congestion::Side::Destination,
385 CompareResult::Same | CompareResult::Different => congestion::Side::Source,
386 };
387 let metadata = crate::walk::run_metadata_probed(
388 side,
389 congestion::MetadataOp::Stat,
390 tokio::fs::symlink_metadata(existing_path),
391 )
392 .await
393 .with_context(|| format!("failed reading metadata from {:?}", &existing_path))?;
394 let existing_obj_type = obj_type(&metadata);
395 let mut summary = Summary::default();
396 summary.mismatch[existing_obj_type][result] += 1;
397 if metadata.is_file() {
399 match result {
400 CompareResult::DstMissing => summary.src_bytes += metadata.len(),
401 CompareResult::SrcMissing => summary.dst_bytes += metadata.len(),
402 _ => {}
403 }
404 }
405 match result {
406 CompareResult::DstMissing => {
407 log.log_mismatch(
408 result,
409 Some(existing_obj_type),
410 existing_path,
411 None,
412 mirror_path,
413 )
414 .await?;
415 }
416 CompareResult::SrcMissing => {
417 log.log_mismatch(
418 result,
419 None,
420 mirror_path,
421 Some(existing_obj_type),
422 existing_path,
423 )
424 .await?;
425 }
426 _ => {}
427 }
428 if settings.exit_early {
429 return Ok(summary);
430 }
431 if !metadata.is_dir() {
432 return Ok(summary);
433 }
434 let mut entries = tokio::fs::read_dir(existing_path)
435 .await
436 .with_context(|| format!("cannot open directory {:?} for reading", &existing_path))?;
437 let mut join_set = tokio::task::JoinSet::new();
438 let errors = crate::error_collector::ErrorCollector::default();
439 loop {
440 let Some((entry, entry_file_type)) =
441 crate::walk::next_entry_probed(&mut entries, side, || {
442 format!("failed traversing directory {:?}", &existing_path)
443 })
444 .await?
445 else {
446 break;
447 };
448 let entry_path = entry.path();
449 let entry_name = entry_path.file_name().unwrap();
450 if let Some(ref filter) = settings.filter {
452 let relative_path = entry_path
453 .strip_prefix(existing_root)
454 .unwrap_or(&entry_path);
455 let is_dir = entry_file_type.map(|ft| ft.is_dir()).unwrap_or(false);
456 if !matches!(
457 filter.should_include(relative_path, is_dir),
458 crate::filter::FilterResult::Included
459 ) {
460 let entry_obj_type = if is_dir {
462 ObjType::Dir
463 } else if entry_file_type.map(|ft| ft.is_symlink()).unwrap_or(false) {
464 ObjType::Symlink
465 } else {
466 ObjType::File
467 };
468 summary.skipped[entry_obj_type] += 1;
469 continue;
470 }
471 }
472 let child_mirror = mirror_path.join(entry_name);
473 let log = log.clone();
474 let settings = settings.clone();
475 let existing_root = existing_root.to_owned();
476 let known_leaf = entry_file_type.is_some_and(|ft| !ft.is_dir());
487 let pending_guard = if known_leaf {
488 Some(throttle::pending_meta_permit().await)
489 } else {
490 None
491 };
492 join_set.spawn(async move {
493 let _pending_guard = pending_guard;
494 expand_missing_tree(
495 prog_track,
496 &entry_path,
497 &child_mirror,
498 &existing_root,
499 result,
500 &log,
501 &settings,
502 )
503 .await
504 });
505 }
506 drop(entries);
507 while let Some(res) = join_set.join_next().await {
508 match res? {
509 Ok(child_summary) => summary = summary + child_summary,
510 Err(error) => {
511 tracing::error!(
512 "expand_missing_tree: {:?} failed with: {:#}",
513 existing_path,
514 &error
515 );
516 errors.push(error);
517 if settings.fail_early {
518 break;
519 }
520 }
521 }
522 }
523 if let Some(err) = errors.into_error() {
524 return Err(err);
525 }
526 Ok(summary)
527}
528
529#[instrument(skip(prog_track))]
530#[async_recursion]
531async fn cmp_internal(
532 prog_track: &'static progress::Progress,
533 src: &std::path::Path,
534 dst: &std::path::Path,
535 source_root: &std::path::Path,
536 dest_root: &std::path::Path,
537 log: &LogWriter,
538 settings: &Settings,
539) -> Result<Summary> {
540 let _prog_guard = prog_track.ops.guard();
541 tracing::debug!("reading source metadata");
542 let src_metadata = crate::walk::run_metadata_probed(
544 congestion::Side::Source,
545 congestion::MetadataOp::Stat,
546 tokio::fs::symlink_metadata(src),
547 )
548 .await
549 .with_context(|| format!("failed reading metadata from {:?}", &src))?;
550 if src == source_root
552 && let Some(filter) = &settings.filter
553 && let Some(name) = src.file_name()
554 {
555 let is_dir = src_metadata.is_dir();
556 if !matches!(
557 filter.should_include_root_item(name.as_ref(), is_dir),
558 crate::filter::FilterResult::Included
559 ) {
560 let src_obj_type = obj_type(&src_metadata);
562 let mut summary = Summary::default();
563 summary.skipped[src_obj_type] += 1;
564 return Ok(summary);
565 }
566 }
567 let mut cmp_summary = Summary::default();
568 let src_obj_type = obj_type(&src_metadata);
569 if src_metadata.is_file() {
571 cmp_summary.src_bytes += src_metadata.len();
572 }
573 let dst_metadata = {
574 let probed = crate::walk::run_metadata_probed(
575 congestion::Side::Destination,
576 congestion::MetadataOp::Stat,
577 tokio::fs::symlink_metadata(dst),
578 )
579 .await;
580 match probed {
581 Ok(metadata) => metadata,
582 Err(err) => {
583 if err.kind() == std::io::ErrorKind::NotFound {
584 if settings.expand_missing && src_metadata.is_dir() {
585 let expanded = expand_missing_tree(
586 prog_track,
587 src,
588 dst,
589 source_root,
590 CompareResult::DstMissing,
591 log,
592 settings,
593 )
594 .await?;
595 cmp_summary = cmp_summary + expanded;
596 } else {
597 cmp_summary.mismatch[src_obj_type][CompareResult::DstMissing] += 1;
598 log.log_mismatch(
599 CompareResult::DstMissing,
600 Some(src_obj_type),
601 src,
602 None,
603 dst,
604 )
605 .await?;
606 }
607 return Ok(cmp_summary);
608 }
609 return Err(err).context(format!("failed reading metadata from {:?}", &dst));
610 }
611 }
612 };
613 if dst_metadata.is_file() {
614 cmp_summary.dst_bytes += dst_metadata.len();
615 }
616 if !is_file_type_same(&src_metadata, &dst_metadata)
617 || !filecmp::metadata_equal(
618 &settings.compare[src_obj_type],
619 &src_metadata,
620 &dst_metadata,
621 )
622 {
623 cmp_summary.mismatch[src_obj_type][CompareResult::Different] += 1;
625 let dst_obj_type = obj_type(&dst_metadata);
626 log.log_mismatch(
627 CompareResult::Different,
628 Some(src_obj_type),
629 src,
630 Some(dst_obj_type),
631 dst,
632 )
633 .await?;
634 if settings.exit_early {
635 return Ok(cmp_summary);
636 }
637 } else {
638 cmp_summary.mismatch[src_obj_type][CompareResult::Same] += 1;
639 }
640 if !src_metadata.is_dir() || !dst_metadata.is_dir() {
641 return Ok(cmp_summary);
643 }
644 tracing::debug!("process contents of 'src' directory");
645 let mut src_entries = tokio::fs::read_dir(src)
646 .await
647 .with_context(|| format!("cannot open directory {src:?} for reading"))?;
648 let mut join_set = tokio::task::JoinSet::new();
649 let errors = crate::error_collector::ErrorCollector::default();
650 let mut processed_files = std::collections::HashSet::new();
652 loop {
654 let Some((src_entry, entry_file_type)) =
655 crate::walk::next_entry_probed(&mut src_entries, congestion::Side::Source, || {
656 format!("failed traversing directory {:?}", &src)
657 })
658 .await?
659 else {
660 break;
661 };
662 let entry_path = src_entry.path();
663 let entry_name = entry_path.file_name().unwrap();
664 if let Some(ref filter) = settings.filter {
666 let relative_path = crate::walk::relative_to_root(&entry_path, source_root);
668 let is_dir = entry_file_type.map(|ft| ft.is_dir()).unwrap_or(false);
669 if !matches!(
670 filter.should_include(relative_path, is_dir),
671 crate::filter::FilterResult::Included
672 ) {
673 let entry_obj_type = if is_dir {
675 ObjType::Dir
676 } else if entry_file_type.map(|ft| ft.is_symlink()).unwrap_or(false) {
677 ObjType::Symlink
678 } else {
679 ObjType::File
680 };
681 cmp_summary.skipped[entry_obj_type] += 1;
682 continue;
683 }
684 }
685 processed_files.insert(entry_name.to_owned());
686 let dst_path = dst.join(entry_name);
687 let log = log.clone();
688 let settings = settings.clone();
689 let source_root = source_root.to_owned();
690 let dest_root = dest_root.to_owned();
691 let known_leaf = entry_file_type.is_some_and(|ft| !ft.is_dir());
702 let pending_guard = if known_leaf {
703 Some(throttle::pending_meta_permit().await)
704 } else {
705 None
706 };
707 let do_cmp = || async move {
708 let _pending_guard = pending_guard;
709 cmp_internal(
710 prog_track,
711 &entry_path,
712 &dst_path,
713 &source_root,
714 &dest_root,
715 &log,
716 &settings,
717 )
718 .await
719 };
720 join_set.spawn(do_cmp());
721 }
722 drop(src_entries);
725 tracing::debug!("process contents of 'dst' directory");
726 let mut dst_entries = tokio::fs::read_dir(dst)
727 .await
728 .with_context(|| format!("cannot open directory {:?} for reading", &dst))?;
729 loop {
731 let Some((dst_entry, entry_file_type)) =
732 crate::walk::next_entry_probed(&mut dst_entries, congestion::Side::Destination, || {
733 format!("failed traversing directory {:?}", &dst)
734 })
735 .await?
736 else {
737 break;
738 };
739 let entry_path = dst_entry.path();
740 let entry_name = entry_path.file_name().unwrap();
741 if processed_files.contains(entry_name) {
742 continue;
744 }
745 if let Some(ref filter) = settings.filter {
747 let relative_path = crate::walk::relative_to_root(&entry_path, dest_root);
749 let is_dir = entry_file_type.map(|ft| ft.is_dir()).unwrap_or(false);
750 if !matches!(
751 filter.should_include(relative_path, is_dir),
752 crate::filter::FilterResult::Included
753 ) {
754 let entry_obj_type = if is_dir {
756 ObjType::Dir
757 } else if entry_file_type.map(|ft| ft.is_symlink()).unwrap_or(false) {
758 ObjType::Symlink
759 } else {
760 ObjType::File
761 };
762 cmp_summary.skipped[entry_obj_type] += 1;
763 continue;
764 }
765 }
766 tracing::debug!("found a new entry in the 'dst' directory");
767 let dst_path = dst.join(entry_name);
768 let dst_entry_metadata = crate::walk::run_metadata_probed(
769 congestion::Side::Destination,
770 congestion::MetadataOp::Stat,
771 tokio::fs::symlink_metadata(&dst_path),
772 )
773 .await
774 .with_context(|| format!("failed reading metadata from {:?}", &dst_path))?;
775 let dst_obj_type = obj_type(&dst_entry_metadata);
776 if settings.expand_missing && dst_entry_metadata.is_dir() {
777 match expand_missing_tree(
778 prog_track,
779 &dst_path,
780 &src.join(entry_name),
781 dest_root,
782 CompareResult::SrcMissing,
783 log,
784 settings,
785 )
786 .await
787 {
788 Ok(expanded) => cmp_summary = cmp_summary + expanded,
789 Err(error) => {
790 tracing::error!(
791 "expand_missing_tree: {:?} failed with: {:#}",
792 &dst_path,
793 &error
794 );
795 errors.push(error);
796 if settings.fail_early {
797 return Err(errors.into_error().unwrap());
799 }
800 }
801 }
802 } else {
803 if dst_entry_metadata.is_file() {
804 cmp_summary.dst_bytes += dst_entry_metadata.len();
805 }
806 cmp_summary.mismatch[dst_obj_type][CompareResult::SrcMissing] += 1;
807 log.log_mismatch(
808 CompareResult::SrcMissing,
809 None,
810 &src.join(entry_name),
811 Some(dst_obj_type),
812 &dst_path,
813 )
814 .await?;
815 }
816 }
817 drop(dst_entries);
820 while let Some(res) = join_set.join_next().await {
821 match res? {
822 Ok(summary) => cmp_summary = cmp_summary + summary,
823 Err(error) => {
824 tracing::error!("cmp: {:?} vs {:?} failed with: {:#}", src, dst, &error);
825 errors.push(error);
826 if settings.fail_early {
827 break;
828 }
829 }
830 }
831 }
832 if let Some(err) = errors.into_error() {
833 return Err(err);
834 }
835 Ok(cmp_summary)
836}
837
838#[cfg(test)]
839mod cmp_tests {
840 use crate::copy;
841 use crate::preserve;
842 use crate::testutils;
843 use enum_map::enum_map;
844 use tracing_test::traced_test;
845
846 use super::*;
847
848 static PROGRESS: std::sync::LazyLock<progress::Progress> =
849 std::sync::LazyLock::new(progress::Progress::new);
850 static NO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
851 std::sync::LazyLock::new(preserve::preserve_none);
852 static DO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
853 std::sync::LazyLock::new(preserve::preserve_all);
854
855 async fn setup_test_dirs(preserve: bool) -> Result<std::path::PathBuf> {
856 let tmp_dir = testutils::setup_test_dir().await?;
857 let test_path = tmp_dir.as_path();
858 copy::copy(
859 &PROGRESS,
860 &test_path.join("foo"),
861 &test_path.join("bar"),
862 ©::Settings {
863 dereference: false,
864 fail_early: false,
865 overwrite: false,
866 overwrite_compare: filecmp::MetadataCmpSettings {
867 size: true,
868 mtime: true,
869 ..Default::default()
870 },
871 overwrite_filter: None,
872 ignore_existing: false,
873 chunk_size: 0,
874 skip_specials: false,
875 remote_copy_buffer_size: 0,
876 filter: None,
877 dry_run: None,
878 delete: None,
879 },
880 if preserve {
881 &DO_PRESERVE_SETTINGS
882 } else {
883 &NO_PRESERVE_SETTINGS
884 },
885 false,
886 )
887 .await?;
888 Ok(tmp_dir)
889 }
890
891 async fn truncate_file(path: &str) -> Result<()> {
892 let file = tokio::fs::File::create(path).await?;
893 file.set_len(0).await?;
894 Ok(())
895 }
896
897 #[tokio::test]
898 #[traced_test]
899 async fn check_basic_cmp() -> Result<()> {
900 let tmp_dir = setup_test_dirs(true).await?;
901 tokio::fs::remove_file(&tmp_dir.join("foo").join("bar").join("1.txt")).await?;
903 tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
905 truncate_file(
907 tmp_dir
908 .join("bar")
909 .join("baz")
910 .join("4.txt")
911 .to_str()
912 .unwrap(),
913 )
914 .await?;
915 tokio::fs::remove_file(&tmp_dir.join("bar").join("bar").join("2.txt")).await?;
917 tokio::fs::File::create(&tmp_dir.join("bar").join("baz").join("7.txt")).await?;
919 let compare_settings = Settings {
920 fail_early: false,
921 exit_early: false,
922 expand_missing: false,
923 compare: enum_map! {
924 ObjType::File => filecmp::MetadataCmpSettings {
925 size: true,
926 mtime: true,
927 ..Default::default()
928 },
929 ObjType::Dir => filecmp::MetadataCmpSettings {
930 mtime: true,
931 ..Default::default()
932 },
933 ObjType::Symlink => filecmp::MetadataCmpSettings {
934 mtime: true,
935 ..Default::default()
936 },
937 ObjType::Other => filecmp::MetadataCmpSettings {
938 mtime: true,
939 ..Default::default()
940 },
941 },
942 filter: None,
943 };
944 let summary = cmp(
945 &PROGRESS,
946 &tmp_dir.join("foo"),
947 &tmp_dir.join("bar"),
948 &LogWriter::new(
949 Some(tmp_dir.join("cmp.log").as_path()),
950 false,
951 OutputFormat::Text,
952 )
953 .await?,
954 &compare_settings,
955 )
956 .await?;
957 let mismatch: Mismatch = enum_map! {
958 ObjType::File => enum_map! {
959 CompareResult::Different => 1,
960 CompareResult::Same => 2,
961 CompareResult::SrcMissing => 2,
962 CompareResult::DstMissing => 1,
963 },
964 ObjType::Dir => enum_map! {
965 CompareResult::Different => 2,
966 CompareResult::Same => 1,
967 CompareResult::SrcMissing => 0,
968 CompareResult::DstMissing => 0,
969 },
970 ObjType::Symlink => enum_map! {
971 CompareResult::Different => 0,
972 CompareResult::Same => 2,
973 CompareResult::SrcMissing => 0,
974 CompareResult::DstMissing => 0,
975 },
976 ObjType::Other => enum_map! {
977 CompareResult::Different => 0,
978 CompareResult::Same => 0,
979 CompareResult::SrcMissing => 0,
980 CompareResult::DstMissing => 0,
981 },
982 };
983 assert_eq!(summary.mismatch, mismatch);
984 assert_eq!(summary.src_bytes, 4);
986 assert_eq!(summary.dst_bytes, 3);
988 Ok(())
989 }
990
991 #[tokio::test]
992 #[traced_test]
993 async fn cmp_with_filter_excludes_files() -> Result<()> {
994 let tmp_dir = setup_test_dirs(true).await?;
995 tokio::fs::write(&tmp_dir.join("bar").join("extra.txt"), "extra").await?;
998 let compare_settings_no_filter = Settings {
1000 fail_early: false,
1001 exit_early: false,
1002 expand_missing: false,
1003 compare: enum_map! {
1004 ObjType::File => filecmp::MetadataCmpSettings {
1005 size: true,
1006 mtime: true,
1007 ..Default::default()
1008 },
1009 ObjType::Dir => filecmp::MetadataCmpSettings::default(),
1010 ObjType::Symlink => filecmp::MetadataCmpSettings::default(),
1011 ObjType::Other => filecmp::MetadataCmpSettings::default(),
1012 },
1013 filter: None,
1014 };
1015 let summary = cmp(
1016 &PROGRESS,
1017 &tmp_dir.join("foo"),
1018 &tmp_dir.join("bar"),
1019 &LogWriter::silent().await?,
1020 &compare_settings_no_filter,
1021 )
1022 .await?;
1023 assert_eq!(
1024 summary.mismatch[ObjType::File][CompareResult::SrcMissing],
1025 1
1026 );
1027 let mut filter = crate::filter::FilterSettings::new();
1029 filter.add_exclude("extra.txt")?;
1030 let compare_settings_with_filter = Settings {
1031 fail_early: false,
1032 exit_early: false,
1033 expand_missing: false,
1034 compare: enum_map! {
1035 ObjType::File => filecmp::MetadataCmpSettings {
1036 size: true,
1037 mtime: true,
1038 ..Default::default()
1039 },
1040 ObjType::Dir => filecmp::MetadataCmpSettings::default(),
1041 ObjType::Symlink => filecmp::MetadataCmpSettings::default(),
1042 ObjType::Other => filecmp::MetadataCmpSettings::default(),
1043 },
1044 filter: Some(filter),
1045 };
1046 let summary = cmp(
1047 &PROGRESS,
1048 &tmp_dir.join("foo"),
1049 &tmp_dir.join("bar"),
1050 &LogWriter::silent().await?,
1051 &compare_settings_with_filter,
1052 )
1053 .await?;
1054 assert_eq!(
1055 summary.mismatch[ObjType::File][CompareResult::SrcMissing],
1056 0
1057 );
1058 Ok(())
1059 }
1060
1061 #[tokio::test]
1062 #[traced_test]
1063 async fn cmp_with_include_only_compares_matching() -> Result<()> {
1064 let tmp_dir = setup_test_dirs(true).await?;
1065 tokio::fs::write(&tmp_dir.join("bar").join("bar").join("1.txt"), "modified").await?;
1068 let mut filter = crate::filter::FilterSettings::new();
1070 filter.add_include("*.rs")?;
1071 let compare_settings = Settings {
1072 fail_early: false,
1073 exit_early: false,
1074 expand_missing: false,
1075 compare: enum_map! {
1076 ObjType::File => filecmp::MetadataCmpSettings {
1077 size: true,
1078 mtime: true,
1079 ..Default::default()
1080 },
1081 ObjType::Dir => filecmp::MetadataCmpSettings::default(),
1082 ObjType::Symlink => filecmp::MetadataCmpSettings::default(),
1083 ObjType::Other => filecmp::MetadataCmpSettings::default(),
1084 },
1085 filter: Some(filter),
1086 };
1087 let summary = cmp(
1088 &PROGRESS,
1089 &tmp_dir.join("foo"),
1090 &tmp_dir.join("bar"),
1091 &LogWriter::silent().await?,
1092 &compare_settings,
1093 )
1094 .await?;
1095 assert_eq!(summary.mismatch[ObjType::File][CompareResult::Different], 0);
1097 assert_eq!(summary.mismatch[ObjType::File][CompareResult::Same], 0);
1098 assert_eq!(
1099 summary.mismatch[ObjType::File][CompareResult::SrcMissing],
1100 0
1101 );
1102 assert_eq!(
1103 summary.mismatch[ObjType::File][CompareResult::DstMissing],
1104 0
1105 );
1106 Ok(())
1107 }
1108
1109 #[tokio::test]
1110 #[traced_test]
1111 async fn cmp_with_path_pattern_filters_nested() -> Result<()> {
1112 let tmp_dir = setup_test_dirs(true).await?;
1115 let mut filter = crate::filter::FilterSettings::new();
1120 filter.add_include("bar/*.txt")?;
1121 let compare_settings = Settings {
1122 fail_early: false,
1123 exit_early: false,
1124 expand_missing: false,
1125 compare: enum_map! {
1126 ObjType::File => filecmp::MetadataCmpSettings {
1127 size: true,
1128 ..Default::default()
1129 },
1130 ObjType::Dir => filecmp::MetadataCmpSettings::default(),
1131 ObjType::Symlink => filecmp::MetadataCmpSettings::default(),
1132 ObjType::Other => filecmp::MetadataCmpSettings::default(),
1133 },
1134 filter: Some(filter),
1135 };
1136 let summary = cmp(
1137 &PROGRESS,
1138 &tmp_dir.join("foo"),
1139 &tmp_dir.join("bar"),
1140 &LogWriter::silent().await?,
1141 &compare_settings,
1142 )
1143 .await?;
1144 assert_eq!(
1147 summary.mismatch[ObjType::File][CompareResult::Same],
1148 3,
1149 "should have 3 same files from bar/*.txt pattern"
1150 );
1151 assert_eq!(summary.mismatch[ObjType::File][CompareResult::Different], 0);
1154 Ok(())
1155 }
1156
1157 #[tokio::test]
1158 #[traced_test]
1159 async fn cmp_filter_applies_to_root_file() -> Result<()> {
1160 let tmp_dir = testutils::create_temp_dir().await?;
1162 tokio::fs::write(tmp_dir.join("test.txt"), "content1").await?;
1164 tokio::fs::write(tmp_dir.join("test2.txt"), "content2").await?;
1165 let mut filter = crate::filter::FilterSettings::new();
1167 filter.add_include("*.rs")?;
1168 let compare_settings = Settings {
1169 fail_early: false,
1170 exit_early: false,
1171 expand_missing: false,
1172 compare: enum_map! {
1173 ObjType::File => filecmp::MetadataCmpSettings {
1174 size: true,
1175 ..Default::default()
1176 },
1177 ObjType::Dir => filecmp::MetadataCmpSettings::default(),
1178 ObjType::Symlink => filecmp::MetadataCmpSettings::default(),
1179 ObjType::Other => filecmp::MetadataCmpSettings::default(),
1180 },
1181 filter: Some(filter),
1182 };
1183 let summary = cmp(
1185 &PROGRESS,
1186 &tmp_dir.join("test.txt"),
1187 &tmp_dir.join("test2.txt"),
1188 &LogWriter::silent().await?,
1189 &compare_settings,
1190 )
1191 .await?;
1192 assert_eq!(summary.mismatch[ObjType::File][CompareResult::Same], 0);
1194 assert_eq!(summary.mismatch[ObjType::File][CompareResult::Different], 0);
1195 Ok(())
1196 }
1197
1198 #[tokio::test]
1199 #[traced_test]
1200 async fn cmp_filter_excludes_root_directory() -> Result<()> {
1201 let tmp_dir = testutils::setup_test_dir().await?;
1203 let mut filter = crate::filter::FilterSettings::new();
1205 filter.add_exclude("foo")?;
1206 let compare_settings = Settings {
1207 fail_early: false,
1208 exit_early: false,
1209 expand_missing: false,
1210 compare: enum_map! {
1211 ObjType::File => filecmp::MetadataCmpSettings::default(),
1212 ObjType::Dir => filecmp::MetadataCmpSettings::default(),
1213 ObjType::Symlink => filecmp::MetadataCmpSettings::default(),
1214 ObjType::Other => filecmp::MetadataCmpSettings::default(),
1215 },
1216 filter: Some(filter),
1217 };
1218 let summary = cmp(
1220 &PROGRESS,
1221 &tmp_dir.join("foo"),
1222 &tmp_dir.join("bar"),
1223 &LogWriter::silent().await?,
1224 &compare_settings,
1225 )
1226 .await?;
1227 assert_eq!(summary.mismatch[ObjType::Dir][CompareResult::Same], 0);
1229 assert_eq!(summary.mismatch[ObjType::Dir][CompareResult::Different], 0);
1230 assert_eq!(summary.mismatch[ObjType::File][CompareResult::Same], 0);
1231 Ok(())
1232 }
1233
1234 #[tokio::test]
1235 #[traced_test]
1236 async fn cmp_combined_include_exclude_patterns() -> Result<()> {
1237 let tmp_dir = setup_test_dirs(true).await?;
1238 let mut filter = crate::filter::FilterSettings::new();
1240 filter.add_include("**/*.txt")?;
1241 filter.add_exclude("bar/2.txt")?;
1242 let compare_settings = Settings {
1243 fail_early: false,
1244 exit_early: false,
1245 expand_missing: false,
1246 compare: enum_map! {
1247 ObjType::File => filecmp::MetadataCmpSettings {
1248 size: true,
1249 ..Default::default()
1250 },
1251 ObjType::Dir => filecmp::MetadataCmpSettings::default(),
1252 ObjType::Symlink => filecmp::MetadataCmpSettings::default(),
1253 ObjType::Other => filecmp::MetadataCmpSettings::default(),
1254 },
1255 filter: Some(filter),
1256 };
1257 let summary = cmp(
1258 &PROGRESS,
1259 &tmp_dir.join("foo"),
1260 &tmp_dir.join("bar"),
1261 &LogWriter::silent().await?,
1262 &compare_settings,
1263 )
1264 .await?;
1265 assert_eq!(
1269 summary.mismatch[ObjType::File][CompareResult::Same],
1270 4,
1271 "should compare 4 .txt files as same"
1272 );
1273 assert_eq!(
1275 summary.skipped[ObjType::File],
1276 2,
1277 "should skip 2 files (bar/2.txt on src and dst)"
1278 );
1279 Ok(())
1280 }
1281
1282 #[tokio::test]
1283 #[traced_test]
1284 async fn cmp_skipped_counts_comprehensive() -> Result<()> {
1285 let tmp_dir = setup_test_dirs(true).await?;
1286 let mut filter = crate::filter::FilterSettings::new();
1288 filter.add_exclude("bar/")?;
1289 let compare_settings = Settings {
1290 fail_early: false,
1291 exit_early: false,
1292 expand_missing: false,
1293 compare: enum_map! {
1294 ObjType::File => filecmp::MetadataCmpSettings {
1295 size: true,
1296 ..Default::default()
1297 },
1298 ObjType::Dir => filecmp::MetadataCmpSettings::default(),
1299 ObjType::Symlink => filecmp::MetadataCmpSettings::default(),
1300 ObjType::Other => filecmp::MetadataCmpSettings::default(),
1301 },
1302 filter: Some(filter),
1303 };
1304 let summary = cmp(
1305 &PROGRESS,
1306 &tmp_dir.join("foo"),
1307 &tmp_dir.join("bar"),
1308 &LogWriter::silent().await?,
1309 &compare_settings,
1310 )
1311 .await?;
1312 assert_eq!(
1316 summary.mismatch[ObjType::File][CompareResult::Same],
1317 2,
1318 "should compare 2 files as same"
1319 );
1320 assert_eq!(
1321 summary.mismatch[ObjType::Symlink][CompareResult::Same],
1322 2,
1323 "should compare 2 symlinks as same"
1324 );
1325 assert_eq!(
1326 summary.skipped[ObjType::Dir],
1327 2,
1328 "should skip 2 directories (bar in src + bar in dst)"
1329 );
1330 Ok(())
1331 }
1332
1333 #[tokio::test]
1334 #[traced_test]
1335 async fn expand_missing_dst_reports_all_entries() -> Result<()> {
1336 let tmp_dir = setup_test_dirs(true).await?;
1337 tokio::fs::remove_dir_all(&tmp_dir.join("bar").join("bar")).await?;
1339 let compare_settings = Settings {
1340 fail_early: false,
1341 exit_early: false,
1342 expand_missing: true,
1343 compare: enum_map! {
1344 ObjType::File => filecmp::MetadataCmpSettings::default(),
1345 ObjType::Dir => filecmp::MetadataCmpSettings::default(),
1346 ObjType::Symlink => filecmp::MetadataCmpSettings::default(),
1347 ObjType::Other => filecmp::MetadataCmpSettings::default(),
1348 },
1349 filter: None,
1350 };
1351 let summary = cmp(
1352 &PROGRESS,
1353 &tmp_dir.join("foo"),
1354 &tmp_dir.join("bar"),
1355 &LogWriter::silent().await?,
1356 &compare_settings,
1357 )
1358 .await?;
1359 assert_eq!(
1361 summary.mismatch[ObjType::Dir][CompareResult::DstMissing],
1362 1,
1363 "should report 1 directory as DstMissing"
1364 );
1365 assert_eq!(
1366 summary.mismatch[ObjType::File][CompareResult::DstMissing],
1367 3,
1368 "should report 3 files as DstMissing"
1369 );
1370 Ok(())
1371 }
1372
1373 #[tokio::test]
1374 #[traced_test]
1375 async fn expand_missing_src_reports_all_entries() -> Result<()> {
1376 let tmp_dir = setup_test_dirs(true).await?;
1377 let newdir = tmp_dir.join("bar").join("newdir");
1379 tokio::fs::create_dir(&newdir).await?;
1380 tokio::fs::write(newdir.join("a.txt"), "a").await?;
1381 tokio::fs::write(newdir.join("b.txt"), "b").await?;
1382 let compare_settings = Settings {
1383 fail_early: false,
1384 exit_early: false,
1385 expand_missing: true,
1386 compare: enum_map! {
1387 ObjType::File => filecmp::MetadataCmpSettings::default(),
1388 ObjType::Dir => filecmp::MetadataCmpSettings::default(),
1389 ObjType::Symlink => filecmp::MetadataCmpSettings::default(),
1390 ObjType::Other => filecmp::MetadataCmpSettings::default(),
1391 },
1392 filter: None,
1393 };
1394 let summary = cmp(
1395 &PROGRESS,
1396 &tmp_dir.join("foo"),
1397 &tmp_dir.join("bar"),
1398 &LogWriter::silent().await?,
1399 &compare_settings,
1400 )
1401 .await?;
1402 assert_eq!(
1403 summary.mismatch[ObjType::Dir][CompareResult::SrcMissing],
1404 1,
1405 "should report 1 directory as SrcMissing"
1406 );
1407 assert_eq!(
1408 summary.mismatch[ObjType::File][CompareResult::SrcMissing],
1409 2,
1410 "should report 2 files as SrcMissing"
1411 );
1412 Ok(())
1413 }
1414
1415 #[tokio::test]
1416 #[traced_test]
1417 async fn expand_missing_dst_deeply_nested() -> Result<()> {
1418 let tmp_dir = testutils::create_temp_dir().await?;
1420 let src = tmp_dir.join("src");
1421 let dst = tmp_dir.join("dst");
1422 tokio::fs::create_dir(&src).await?;
1423 tokio::fs::create_dir(&dst).await?;
1424 let deep = src.join("a").join("b").join("c");
1426 tokio::fs::create_dir_all(&deep).await?;
1427 tokio::fs::write(deep.join("d.txt"), "d").await?;
1428 tokio::fs::write(src.join("a").join("b").join("mid.txt"), "m").await?;
1430 let compare_settings = Settings {
1432 fail_early: false,
1433 exit_early: false,
1434 expand_missing: true,
1435 compare: enum_map! {
1436 ObjType::File => filecmp::MetadataCmpSettings::default(),
1437 ObjType::Dir => filecmp::MetadataCmpSettings::default(),
1438 ObjType::Symlink => filecmp::MetadataCmpSettings::default(),
1439 ObjType::Other => filecmp::MetadataCmpSettings::default(),
1440 },
1441 filter: None,
1442 };
1443 let summary = cmp(
1444 &PROGRESS,
1445 &src,
1446 &dst,
1447 &LogWriter::silent().await?,
1448 &compare_settings,
1449 )
1450 .await?;
1451 assert_eq!(
1453 summary.mismatch[ObjType::Dir][CompareResult::DstMissing],
1454 3,
1455 "should report 3 nested directories as DstMissing"
1456 );
1457 assert_eq!(
1459 summary.mismatch[ObjType::File][CompareResult::DstMissing],
1460 2,
1461 "should report 2 files as DstMissing"
1462 );
1463 assert_eq!(
1465 summary.src_bytes, 2,
1466 "should track bytes for expanded files"
1467 );
1468 Ok(())
1469 }
1470
1471 #[tokio::test]
1472 #[traced_test]
1473 async fn expand_missing_src_deeply_nested() -> Result<()> {
1474 let tmp_dir = testutils::create_temp_dir().await?;
1476 let src = tmp_dir.join("src");
1477 let dst = tmp_dir.join("dst");
1478 tokio::fs::create_dir(&src).await?;
1479 tokio::fs::create_dir(&dst).await?;
1480 let deep = dst.join("x").join("y");
1482 tokio::fs::create_dir_all(&deep).await?;
1483 tokio::fs::write(deep.join("z.txt"), "zz").await?;
1484 let compare_settings = Settings {
1485 fail_early: false,
1486 exit_early: false,
1487 expand_missing: true,
1488 compare: enum_map! {
1489 ObjType::File => filecmp::MetadataCmpSettings::default(),
1490 ObjType::Dir => filecmp::MetadataCmpSettings::default(),
1491 ObjType::Symlink => filecmp::MetadataCmpSettings::default(),
1492 ObjType::Other => filecmp::MetadataCmpSettings::default(),
1493 },
1494 filter: None,
1495 };
1496 let summary = cmp(
1497 &PROGRESS,
1498 &src,
1499 &dst,
1500 &LogWriter::silent().await?,
1501 &compare_settings,
1502 )
1503 .await?;
1504 assert_eq!(
1506 summary.mismatch[ObjType::Dir][CompareResult::SrcMissing],
1507 2,
1508 "should report 2 nested directories as SrcMissing"
1509 );
1510 assert_eq!(
1512 summary.mismatch[ObjType::File][CompareResult::SrcMissing],
1513 1,
1514 "should report 1 file as SrcMissing"
1515 );
1516 assert_eq!(
1518 summary.dst_bytes, 2,
1519 "should track bytes for expanded files"
1520 );
1521 Ok(())
1522 }
1523
1524 #[tokio::test]
1525 #[traced_test]
1526 async fn expand_missing_with_exclude_filter() -> Result<()> {
1527 let tmp_dir = testutils::create_temp_dir().await?;
1530 let src = tmp_dir.join("src");
1531 let dst = tmp_dir.join("dst");
1532 tokio::fs::create_dir(&src).await?;
1533 tokio::fs::create_dir(&dst).await?;
1534 let missing = src.join("missing_dir");
1536 tokio::fs::create_dir(&missing).await?;
1537 tokio::fs::write(missing.join("keep.txt"), "k").await?;
1538 tokio::fs::write(missing.join("skip.log"), "s").await?;
1539 tokio::fs::write(missing.join("also_keep.txt"), "a").await?;
1540 let mut filter = crate::filter::FilterSettings::new();
1541 filter.add_exclude("*.log")?;
1542 let compare_settings = Settings {
1543 fail_early: false,
1544 exit_early: false,
1545 expand_missing: true,
1546 compare: enum_map! {
1547 ObjType::File => filecmp::MetadataCmpSettings::default(),
1548 ObjType::Dir => filecmp::MetadataCmpSettings::default(),
1549 ObjType::Symlink => filecmp::MetadataCmpSettings::default(),
1550 ObjType::Other => filecmp::MetadataCmpSettings::default(),
1551 },
1552 filter: Some(filter),
1553 };
1554 let summary = cmp(
1555 &PROGRESS,
1556 &src,
1557 &dst,
1558 &LogWriter::silent().await?,
1559 &compare_settings,
1560 )
1561 .await?;
1562 assert_eq!(summary.mismatch[ObjType::Dir][CompareResult::DstMissing], 1,);
1564 assert_eq!(
1566 summary.mismatch[ObjType::File][CompareResult::DstMissing],
1567 2,
1568 "should report only non-excluded files as DstMissing"
1569 );
1570 assert_eq!(
1572 summary.skipped[ObjType::File],
1573 1,
1574 "should count excluded file as skipped"
1575 );
1576 Ok(())
1577 }
1578
1579 #[tokio::test]
1580 #[traced_test]
1581 async fn expand_missing_with_include_filter() -> Result<()> {
1582 let tmp_dir = testutils::create_temp_dir().await?;
1584 let src = tmp_dir.join("src");
1585 let dst = tmp_dir.join("dst");
1586 tokio::fs::create_dir(&src).await?;
1587 tokio::fs::create_dir(&dst).await?;
1588 let data = src.join("data");
1590 tokio::fs::create_dir(&data).await?;
1591 tokio::fs::write(data.join("a.rs"), "fn main() {}").await?;
1592 tokio::fs::write(data.join("b.txt"), "hello").await?;
1593 tokio::fs::write(data.join("c.rs"), "fn test() {}").await?;
1594 let mut filter = crate::filter::FilterSettings::new();
1595 filter.add_include("**/*.rs")?;
1596 let compare_settings = Settings {
1597 fail_early: false,
1598 exit_early: false,
1599 expand_missing: true,
1600 compare: enum_map! {
1601 ObjType::File => filecmp::MetadataCmpSettings::default(),
1602 ObjType::Dir => filecmp::MetadataCmpSettings::default(),
1603 ObjType::Symlink => filecmp::MetadataCmpSettings::default(),
1604 ObjType::Other => filecmp::MetadataCmpSettings::default(),
1605 },
1606 filter: Some(filter),
1607 };
1608 let summary = cmp(
1609 &PROGRESS,
1610 &src,
1611 &dst,
1612 &LogWriter::silent().await?,
1613 &compare_settings,
1614 )
1615 .await?;
1616 assert_eq!(summary.mismatch[ObjType::Dir][CompareResult::DstMissing], 1,);
1618 assert_eq!(
1620 summary.mismatch[ObjType::File][CompareResult::DstMissing],
1621 2,
1622 "should report only included files as DstMissing"
1623 );
1624 Ok(())
1625 }
1626
1627 #[tokio::test]
1628 #[traced_test]
1629 async fn expand_missing_with_nested_path_filter() -> Result<()> {
1630 let tmp_dir = testutils::create_temp_dir().await?;
1633 let src = tmp_dir.join("src");
1634 let dst = tmp_dir.join("dst");
1635 tokio::fs::create_dir(&src).await?;
1636 tokio::fs::create_dir(&dst).await?;
1637 let top = src.join("top");
1639 let keep = top.join("keep");
1640 let skip = top.join("skip");
1641 tokio::fs::create_dir_all(&keep).await?;
1642 tokio::fs::create_dir_all(&skip).await?;
1643 tokio::fs::write(keep.join("1.txt"), "1").await?;
1644 tokio::fs::write(keep.join("2.txt"), "2").await?;
1645 tokio::fs::write(skip.join("3.txt"), "3").await?;
1646 let mut filter = crate::filter::FilterSettings::new();
1647 filter.add_include("top/keep/**")?;
1648 let compare_settings = Settings {
1649 fail_early: false,
1650 exit_early: false,
1651 expand_missing: true,
1652 compare: enum_map! {
1653 ObjType::File => filecmp::MetadataCmpSettings::default(),
1654 ObjType::Dir => filecmp::MetadataCmpSettings::default(),
1655 ObjType::Symlink => filecmp::MetadataCmpSettings::default(),
1656 ObjType::Other => filecmp::MetadataCmpSettings::default(),
1657 },
1658 filter: Some(filter),
1659 };
1660 let summary = cmp(
1661 &PROGRESS,
1662 &src,
1663 &dst,
1664 &LogWriter::silent().await?,
1665 &compare_settings,
1666 )
1667 .await?;
1668 assert_eq!(
1670 summary.mismatch[ObjType::Dir][CompareResult::DstMissing],
1671 2,
1672 "should report top and keep dirs as DstMissing"
1673 );
1674 assert_eq!(
1676 summary.mismatch[ObjType::File][CompareResult::DstMissing],
1677 2,
1678 "should report only files under keep/ as DstMissing"
1679 );
1680 Ok(())
1681 }
1682
1683 #[tokio::test]
1684 #[traced_test]
1685 async fn expand_missing_false_preserves_original_behavior() -> Result<()> {
1686 let tmp_dir = setup_test_dirs(true).await?;
1687 tokio::fs::remove_dir_all(&tmp_dir.join("bar").join("bar")).await?;
1689 let compare_settings = Settings {
1690 fail_early: false,
1691 exit_early: false,
1692 expand_missing: false,
1693 compare: enum_map! {
1694 ObjType::File => filecmp::MetadataCmpSettings::default(),
1695 ObjType::Dir => filecmp::MetadataCmpSettings::default(),
1696 ObjType::Symlink => filecmp::MetadataCmpSettings::default(),
1697 ObjType::Other => filecmp::MetadataCmpSettings::default(),
1698 },
1699 filter: None,
1700 };
1701 let summary = cmp(
1702 &PROGRESS,
1703 &tmp_dir.join("foo"),
1704 &tmp_dir.join("bar"),
1705 &LogWriter::silent().await?,
1706 &compare_settings,
1707 )
1708 .await?;
1709 assert_eq!(
1711 summary.mismatch[ObjType::Dir][CompareResult::DstMissing],
1712 1,
1713 "should report only 1 directory as DstMissing"
1714 );
1715 assert_eq!(
1716 summary.mismatch[ObjType::File][CompareResult::DstMissing],
1717 0,
1718 "should not report individual files as DstMissing"
1719 );
1720 Ok(())
1721 }
1722
1723 #[test]
1724 fn path_to_json_string_utf8() {
1725 let path = std::path::Path::new("/foo/bar/baz.txt");
1726 assert_eq!(path_to_json_string(path), "/foo/bar/baz.txt");
1727 }
1728
1729 #[test]
1730 fn path_to_json_string_non_utf8() {
1731 use std::ffi::OsStr;
1732 use std::os::unix::ffi::OsStrExt;
1733 let os_str = OsStr::from_bytes(b"/tmp/bad\xffname.txt");
1735 let path = std::path::Path::new(os_str);
1736 assert_eq!(path_to_json_string(path), "/tmp/bad\\xffname.txt");
1737 }
1738
1739 #[test]
1740 fn path_to_json_string_multiple_bad_bytes() {
1741 use std::ffi::OsStr;
1742 use std::os::unix::ffi::OsStrExt;
1743 let os_str = OsStr::from_bytes(b"\x80/ok/\xfe\xff/end");
1744 let path = std::path::Path::new(os_str);
1745 assert_eq!(path_to_json_string(path), "\\x80/ok/\\xfe\\xff/end");
1746 }
1747
1748 #[test]
1749 fn path_to_json_string_escapes_backslashes() {
1750 let path = std::path::Path::new("/tmp/bad\\xffname.txt");
1753 assert_eq!(path_to_json_string(path), "/tmp/bad\\\\xffname.txt");
1754 }
1755
1756 #[test]
1757 fn path_to_json_string_no_collision() {
1758 use std::ffi::OsStr;
1759 use std::os::unix::ffi::OsStrExt;
1760 let literal = std::path::Path::new("/tmp/bad\\xffname.txt");
1762 let raw = std::path::Path::new(OsStr::from_bytes(b"/tmp/bad\xffname.txt"));
1764 assert_ne!(path_to_json_string(literal), path_to_json_string(raw));
1766 }
1767
1768 mod max_files_in_flight_tests {
1770 use super::*;
1771 use anyhow::Context;
1772
1773 #[tokio::test]
1776 #[traced_test]
1777 async fn deep_tree_no_deadlock_under_files_in_flight_saturation() -> Result<()> {
1778 let tmp_dir = testutils::create_temp_dir().await?;
1779 let src = tmp_dir.join("src");
1780 let dst = tmp_dir.join("dst");
1781 let depth = 20;
1782 let files_per_level = 5;
1783 let limit = 4;
1784 let mut src_dir = src.clone();
1786 let mut dst_dir = dst.clone();
1787 for level in 0..depth {
1788 tokio::fs::create_dir_all(&src_dir).await?;
1789 tokio::fs::create_dir_all(&dst_dir).await?;
1790 for f in 0..files_per_level {
1791 let name = format!("f{}_{}.txt", level, f);
1792 let content = format!("L{}F{}", level, f);
1793 tokio::fs::write(src_dir.join(&name), &content).await?;
1794 tokio::fs::write(dst_dir.join(&name), &content).await?;
1795 }
1796 src_dir = src_dir.join(format!("d{}", level));
1797 dst_dir = dst_dir.join(format!("d{}", level));
1798 }
1799 let admission = crate::testutils::AdmissionLimit::new().await;
1800 admission.set_files_in_flight(limit);
1801 let compare_settings = Settings {
1802 fail_early: false,
1803 exit_early: false,
1804 expand_missing: false,
1805 compare: enum_map::enum_map! {
1806 ObjType::File => filecmp::MetadataCmpSettings { size: true, ..Default::default() },
1807 ObjType::Dir => filecmp::MetadataCmpSettings::default(),
1808 ObjType::Symlink => filecmp::MetadataCmpSettings::default(),
1809 ObjType::Other => filecmp::MetadataCmpSettings::default(),
1810 },
1811 filter: None,
1812 };
1813 let summary = admission
1814 .run_with_timeout(
1815 std::time::Duration::from_secs(30),
1816 cmp(
1817 &PROGRESS,
1818 &src,
1819 &dst,
1820 &LogWriter::silent().await?,
1821 &compare_settings,
1822 ),
1823 )
1824 .await
1825 .context("cmp timed out — possible deadlock")?
1826 .context("cmp failed")?;
1827 assert_eq!(
1828 summary.mismatch[ObjType::File][CompareResult::Same],
1829 depth * files_per_level
1830 );
1831 Ok(())
1832 }
1833
1834 #[tokio::test]
1837 #[traced_test]
1838 async fn expand_missing_under_files_in_flight_saturation() -> Result<()> {
1839 let tmp_dir = testutils::create_temp_dir().await?;
1840 let src = tmp_dir.join("src");
1841 let dst = tmp_dir.join("dst");
1842 let depth = 10;
1843 let files_per_level = 5;
1844 let limit = 4;
1845 let mut dir = src.clone();
1847 for level in 0..depth {
1848 tokio::fs::create_dir_all(&dir).await?;
1849 for f in 0..files_per_level {
1850 tokio::fs::write(dir.join(format!("f{}_{}.txt", level, f)), "x").await?;
1851 }
1852 dir = dir.join(format!("d{}", level));
1853 }
1854 tokio::fs::create_dir(&dst).await?;
1855 let admission = crate::testutils::AdmissionLimit::new().await;
1856 admission.set_files_in_flight(limit);
1857 let compare_settings = Settings {
1858 fail_early: false,
1859 exit_early: false,
1860 expand_missing: true,
1861 compare: enum_map::enum_map! {
1862 ObjType::File => filecmp::MetadataCmpSettings::default(),
1863 ObjType::Dir => filecmp::MetadataCmpSettings::default(),
1864 ObjType::Symlink => filecmp::MetadataCmpSettings::default(),
1865 ObjType::Other => filecmp::MetadataCmpSettings::default(),
1866 },
1867 filter: None,
1868 };
1869 let summary = admission
1870 .run_with_timeout(
1871 std::time::Duration::from_secs(30),
1872 cmp(
1873 &PROGRESS,
1874 &src,
1875 &dst,
1876 &LogWriter::silent().await?,
1877 &compare_settings,
1878 ),
1879 )
1880 .await
1881 .context("cmp timed out — possible deadlock")?
1882 .context("cmp failed")?;
1883 assert_eq!(
1885 summary.mismatch[ObjType::File][CompareResult::DstMissing],
1886 depth * files_per_level
1887 );
1888 Ok(())
1889 }
1890 }
1891}