Skip to main content

common/
cmp.rs

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, // object missing in src but present in dst
16    DstMissing, // same as above but flipped
17}
18
19#[derive(Copy, Clone, Debug, Enum)]
20pub enum ObjType {
21    File,
22    Dir,
23    Symlink,
24    Other, // sockets, block devices, character devices, FIFOs, etc.
25}
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
40/// Count of skipped items per object type
41pub type Skipped = EnumMap<ObjType, u64>;
42
43/// Output format for comparison results and summary.
44#[derive(Copy, Clone, Debug, Default, clap::ValueEnum)]
45pub enum OutputFormat {
46    /// JSON output (NDJSON for differences, JSON object for summary)
47    #[default]
48    Json,
49    /// Human-readable text output (legacy format)
50    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
71/// Encodes a path as a JSON-safe string that is round-trippable for arbitrary
72/// Unix paths. Literal backslashes are escaped as `\\`, and non-UTF-8 bytes
73/// are escaped as `\xHH`. To decode, first parse the JSON string, then scan
74/// left-to-right: `\\` → literal `\`, `\xHH` → raw byte, all other characters
75/// are literal UTF-8.
76fn 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    /// Total size of regular files compared on the source side, in bytes.
101    pub src_bytes: u64,
102    /// Total size of regular files compared on the destination side, in bytes.
103    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
154/// Wraps a [`Summary`] with an [`OutputFormat`] so that [`Display`](std::fmt::Display)
155/// renders either human-readable text or JSON.
156pub 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    /// Creates a new LogWriter.
226    ///
227    /// If `log_path_opt` is provided, output goes to that file.
228    /// Otherwise, if `use_stdout` is true, output goes to stdout.
229    /// If both are false/None, no output is produced.
230    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    /// Creates a silent LogWriter that produces no output, using the default format.
266    /// Convenience constructor primarily for tests.
267    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        // sockets, block devices, character devices, FIFOs, etc.
346        ObjType::Other
347    }
348}
349
350/// Public entry point for compare operations.
351/// Internally delegates to cmp_internal with source_root/dest_root tracking for proper filter matching.
352#[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/// Recursively walks a directory tree on the existing side and records every entry as missing
364/// on the other side.
365#[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    // The side we probe against is fully determined by which tree is
378    // missing: `DstMissing` means we're enumerating src, `SrcMissing`
379    // means we're enumerating dst. `Same` / `Different` are never
380    // passed in (the only two call sites pass the `*Missing` variants),
381    // but be defensive and default to source.
382    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    // track file sizes on the appropriate side
398    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        // apply filter if configured
451        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                // increment skipped counter based on entry type
461                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        // for positively-known leaf entries (file/symlink/special), acquire
477        // the pending-meta permit BEFORE spawning so we don't create
478        // unbounded tasks. We deliberately skip pre-acquire when
479        // `entry_file_type` is None: the entry could actually be a directory,
480        // and chained unknown-typed directories holding permits while
481        // recursing would deadlock the pending-meta pool. Known directories
482        // also skip pre-acquire. We use the pending-meta semaphore (not
483        // OpenFile) because cmp retains no per-leaf data/classification fd; recursive `ReadDir`
484        // descriptors remain outside leaf admission. Decoupling avoids contention with concurrent
485        // copy paths that hold OpenFile permits
486        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    // it is impossible for src not exist other than user passing invalid path (which is an error)
543    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    // apply filter to root item (when src == source_root, this is the initial call)
551    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            // root item filtered out, return summary with skipped count
561            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    // track file sizes for the summary
570    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        // we use the src type for the summary attribution
624        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        // nothing more to do
642        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    // create a set of all the files we already processed
651    let mut processed_files = std::collections::HashSet::new();
652    // iterate through src entries and recursively call "cmp" on each one
653    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        // apply filter if configured
665        if let Some(ref filter) = settings.filter {
666            // compute relative path from source_root for filter matching
667            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                // increment skipped counter based on entry type
674                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        // for positively-known leaf entries (file/symlink/special), acquire
692        // the pending-meta permit BEFORE spawning so we don't create
693        // unbounded tasks. We deliberately skip pre-acquire when
694        // `entry_file_type` is None: the entry could actually be a directory,
695        // and chained unknown-typed directories holding permits while
696        // recursing would deadlock the pending-meta pool. Known directories
697        // also skip pre-acquire. We use the pending-meta semaphore (not
698        // OpenFile) because cmp retains no per-leaf data/classification fd; recursive `ReadDir`
699        // descriptors remain outside leaf admission. Decoupling avoids contention with concurrent
700        // copy paths that hold OpenFile permits
701        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    // unfortunately ReadDir is opening file-descriptors and there's not a good way to limit this,
723    // one thing we CAN do however is to drop it as soon as we're done with it
724    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    // iterate through update entries and log each one that's not present in src
730    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            // we already must have considered this file, skip it
743            continue;
744        }
745        // apply filter if configured - if this entry would be filtered, don't report as missing
746        if let Some(ref filter) = settings.filter {
747            // compute relative path from dest_root for filter matching
748            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                // increment skipped counter based on entry type
755                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                        // unwrap is safe: we just pushed an error
798                        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    // unfortunately ReadDir is opening file-descriptors and there's not a good way to limit this,
818    // one thing we CAN do however is to drop it as soon as we're done with it
819    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            &copy::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        // drop 1 file from src
902        tokio::fs::remove_file(&tmp_dir.join("foo").join("bar").join("1.txt")).await?;
903        // sleep to ensure mtime is different, this acts as a poor-mans barrier
904        tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
905        // modify 1 file in dst
906        truncate_file(
907            tmp_dir
908                .join("bar")
909                .join("baz")
910                .join("4.txt")
911                .to_str()
912                .unwrap(),
913        )
914        .await?;
915        // drop 1 (other) file from dst
916        tokio::fs::remove_file(&tmp_dir.join("bar").join("bar").join("2.txt")).await?;
917        // create one more file in dst -- this will also modify the mtime of the directory
918        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        // src has 4 regular files of 1 byte each (0.txt, bar/2.txt, bar/3.txt, baz/4.txt)
985        assert_eq!(summary.src_bytes, 4);
986        // dst has: 0.txt(1B), bar/1.txt(1B, SrcMissing), bar/3.txt(1B), baz/4.txt(0B, truncated), baz/7.txt(0B, SrcMissing)
987        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        // setup: src=foo, dst=bar (identical at this point)
996        // add a file to dst that would be reported as SrcMissing
997        tokio::fs::write(&tmp_dir.join("bar").join("extra.txt"), "extra").await?;
998        // without filter, should report extra.txt as SrcMissing
999        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        // with filter excluding extra.txt, should not report it
1028        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        // setup: src=foo, dst=bar (identical at this point)
1066        // modify a file that won't be included
1067        tokio::fs::write(&tmp_dir.join("bar").join("bar").join("1.txt"), "modified").await?;
1068        // with include pattern for only *.rs files, the .txt modification shouldn't appear
1069        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        // no differences should be reported since all .txt files are excluded
1096        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        // test that path-based patterns like "bar/*.txt" work correctly when recursing
1113        // this verifies source_root tracking is working properly
1114        let tmp_dir = setup_test_dirs(true).await?;
1115        // test structure:
1116        // foo/bar/1.txt, foo/bar/2.txt, foo/bar/3.txt
1117        // foo/baz/4.txt, foo/baz/5.txt (symlink), foo/baz/6.txt (symlink)
1118        // filter: only include bar/*.txt
1119        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        // should only compare files in bar/ subdirectory (3 files: 1.txt, 2.txt, 3.txt)
1145        // all should be "Same" since we copied foo to bar earlier
1146        assert_eq!(
1147            summary.mismatch[ObjType::File][CompareResult::Same],
1148            3,
1149            "should have 3 same files from bar/*.txt pattern"
1150        );
1151        // files in baz/ should not be compared (filtered out)
1152        // 0.txt at root should not be compared
1153        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        // test that filters apply to the root item itself
1161        let tmp_dir = testutils::create_temp_dir().await?;
1162        // create two different files
1163        tokio::fs::write(tmp_dir.join("test.txt"), "content1").await?;
1164        tokio::fs::write(tmp_dir.join("test2.txt"), "content2").await?;
1165        // filter: only include *.rs files
1166        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        // compare test.txt vs test2.txt - should be filtered out (not *.rs)
1184        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        // should return empty summary since root file is filtered
1193        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        // test that filters apply to root directories
1202        let tmp_dir = testutils::setup_test_dir().await?;
1203        // filter: exclude directories named "foo"
1204        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        // compare foo vs bar - foo should be filtered out
1219        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        // should return empty summary since root dir is excluded
1228        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        // include all .txt files, but exclude bar/2.txt specifically
1239        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        // should compare: 0.txt, bar/1.txt, bar/3.txt, baz/4.txt = 4 files (same)
1266        // should skip: bar/2.txt (excluded by pattern), 5.txt and 6.txt (symlinks, no match for *.txt in src dir) = 1 file + 2 symlinks
1267        // note: the pattern **/*.txt only matches files with .txt extension, but 5.txt and 6.txt in baz are symlinks
1268        assert_eq!(
1269            summary.mismatch[ObjType::File][CompareResult::Same],
1270            4,
1271            "should compare 4 .txt files as same"
1272        );
1273        // bar/2.txt is skipped for both src and dst traversal = 2 skipped
1274        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        // exclude bar/ directory entirely
1287        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        // compared: 0.txt (same), baz/4.txt (same) = 2 files
1313        // compared: baz/5.txt symlink (same), baz/6.txt symlink (same) = 2 symlinks
1314        // skipped: bar directory in src and dst = 2 dirs (cmp traverses both)
1315        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        // remove bar/bar directory entirely from dst
1338        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        // bar/bar dir has: bar/ (1 dir) + 1.txt, 2.txt, 3.txt (3 files)
1360        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        // create a new subdir in dst with files
1378        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        // verify expansion recurses through multiple directory levels
1419        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        // create src/a/b/c/d.txt -- 3 dirs deep
1425        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        // also add a sibling file at an intermediate level
1429        tokio::fs::write(src.join("a").join("b").join("mid.txt"), "m").await?;
1430        // dst exists but is empty -- everything in src is DstMissing
1431        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        // dirs: a, a/b, a/b/c = 3 DstMissing dirs
1452        assert_eq!(
1453            summary.mismatch[ObjType::Dir][CompareResult::DstMissing],
1454            3,
1455            "should report 3 nested directories as DstMissing"
1456        );
1457        // files: a/b/c/d.txt, a/b/mid.txt = 2 DstMissing files
1458        assert_eq!(
1459            summary.mismatch[ObjType::File][CompareResult::DstMissing],
1460            2,
1461            "should report 2 files as DstMissing"
1462        );
1463        // src_bytes: d.txt(1) + mid.txt(1) = 2
1464        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        // verify expansion recurses for SrcMissing through multiple levels
1475        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        // create dst/x/y/z.txt -- dirs only in dst
1481        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        // dirs: x, x/y = 2 SrcMissing dirs
1505        assert_eq!(
1506            summary.mismatch[ObjType::Dir][CompareResult::SrcMissing],
1507            2,
1508            "should report 2 nested directories as SrcMissing"
1509        );
1510        // files: x/y/z.txt = 1 SrcMissing file
1511        assert_eq!(
1512            summary.mismatch[ObjType::File][CompareResult::SrcMissing],
1513            1,
1514            "should report 1 file as SrcMissing"
1515        );
1516        // dst_bytes: z.txt(2)
1517        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        // verify that filters are applied during expansion. exclude *.log files
1528        // from the missing subtree
1529        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        // src/missing_dir/ has mixed files
1535        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        // missing_dir itself = 1 DstMissing dir
1563        assert_eq!(summary.mismatch[ObjType::Dir][CompareResult::DstMissing], 1,);
1564        // only keep.txt and also_keep.txt should be reported. skip.log is filtered
1565        assert_eq!(
1566            summary.mismatch[ObjType::File][CompareResult::DstMissing],
1567            2,
1568            "should report only non-excluded files as DstMissing"
1569        );
1570        // skip.log should be counted as skipped
1571        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        // verify that include filters restrict which children are reported during expansion
1583        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        // src/data/ has a mix of file types
1589        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        // data dir = 1 DstMissing dir
1617        assert_eq!(summary.mismatch[ObjType::Dir][CompareResult::DstMissing], 1,);
1618        // only a.rs and c.rs should be reported. b.txt is filtered out
1619        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        // verify path-based patterns work correctly during expansion.
1631        // only include files under a specific nested path
1632        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        // src/top/ has two subdirs: keep/ and skip/
1638        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        // only keep/ subtree: keep dir(1) + top dir(1) = 2 dirs. skip dir is filtered
1669        assert_eq!(
1670            summary.mismatch[ObjType::Dir][CompareResult::DstMissing],
1671            2,
1672            "should report top and keep dirs as DstMissing"
1673        );
1674        // only 1.txt and 2.txt from keep/
1675        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        // remove bar/bar directory entirely from dst
1688        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        // without expand_missing, only the top-level dir is reported
1710        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        // embed 0xFF byte in the middle
1734        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        // a path with a literal backslash must be escaped so it doesn't
1751        // collide with \xHH byte escapes
1752        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        // literal backslash-x-f-f in the filename
1761        let literal = std::path::Path::new("/tmp/bad\\xffname.txt");
1762        // actual 0xFF byte in the filename
1763        let raw = std::path::Path::new(OsStr::from_bytes(b"/tmp/bad\xffname.txt"));
1764        // these must produce different output
1765        assert_ne!(path_to_json_string(literal), path_to_json_string(raw));
1766    }
1767
1768    /// Stress tests exercising max-files-in-flight saturation during cmp.
1769    mod max_files_in_flight_tests {
1770        use super::*;
1771        use anyhow::Context;
1772
1773        /// Deep + wide cmp: a directory tree deeper than the files-in-flight limit, with files at every
1774        /// level. Verifies directories do not retain pending-metadata permits across recursion.
1775        #[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            // create matching directory chains in src and dst, deeper than the permit limit
1785            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        /// expand_missing under saturation: dst is empty, src is a deep tree.
1835        /// verifies expand_missing_tree's recursion bounds tasks under the permit cap.
1836        #[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            // create a deep tree in src; dst stays empty
1846            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            // every file under src is missing on dst
1884            assert_eq!(
1885                summary.mismatch[ObjType::File][CompareResult::DstMissing],
1886                depth * files_per_level
1887            );
1888            Ok(())
1889        }
1890    }
1891}