Skip to main content

common/
rm.rs

1use anyhow::{Context, anyhow};
2use async_recursion::async_recursion;
3use std::os::unix::fs::PermissionsExt;
4use tracing::instrument;
5
6use crate::filter::TimeFilter;
7use crate::progress;
8use crate::walk::{self, EntryKind};
9
10/// Error type for remove operations. See [`crate::error::OperationError`] for
11/// logging conventions and rationale.
12pub type Error = crate::error::OperationError<Summary>;
13
14#[derive(Debug, Clone)]
15pub struct Settings {
16    pub fail_early: bool,
17    /// filter settings for include/exclude patterns
18    pub filter: Option<crate::filter::FilterSettings>,
19    /// time-based filter (mtime/btime); applied to each entry individually (files,
20    /// symlinks, and directories). This is an entry filter, not a subtree gate:
21    /// directories are always traversed, and the filter only decides whether each
22    /// entry — including the directory itself, after its children are processed — is
23    /// eligible for removal. A directory whose own timestamps are too recent is left
24    /// intact even when its children have been removed; a non-empty leftover directory
25    /// is logged at info and not treated as an error.
26    pub time_filter: Option<TimeFilter>,
27    /// dry-run mode for previewing operations
28    pub dry_run: Option<crate::config::DryRunMode>,
29}
30
31/// Returns true when `err`'s chain contains an `io::Error` with `ErrorKind::Unsupported`.
32/// Used to downgrade time-filter eval failures on filesystems / entry types that don't
33/// report btime (e.g. many symlinks) from `error!` to `warn!` so they don't flood logs
34/// on otherwise-successful runs.
35fn is_unsupported_io_error(err: &anyhow::Error) -> bool {
36    err.chain().any(|cause| {
37        cause
38            .downcast_ref::<std::io::Error>()
39            .is_some_and(|io_err| io_err.kind() == std::io::ErrorKind::Unsupported)
40    })
41}
42
43/// Summary with the appropriate `*_skipped` counter set to 1 for the given entry kind.
44/// Special files count as `files_skipped` to match the historical mapping used
45/// when filters skip an entry.
46fn skipped_summary_for(kind: EntryKind) -> Summary {
47    match kind {
48        EntryKind::Dir => Summary {
49            directories_skipped: 1,
50            ..Default::default()
51        },
52        EntryKind::Symlink => Summary {
53            symlinks_skipped: 1,
54            ..Default::default()
55        },
56        EntryKind::File | EntryKind::Special => Summary {
57            files_skipped: 1,
58            ..Default::default()
59        },
60    }
61}
62
63#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
64pub struct Summary {
65    pub bytes_removed: u64,
66    pub files_removed: usize,
67    pub symlinks_removed: usize,
68    pub directories_removed: usize,
69    pub files_skipped: usize,
70    pub symlinks_skipped: usize,
71    pub directories_skipped: usize,
72}
73
74impl std::ops::Add for Summary {
75    type Output = Self;
76    fn add(self, other: Self) -> Self {
77        Self {
78            bytes_removed: self.bytes_removed + other.bytes_removed,
79            files_removed: self.files_removed + other.files_removed,
80            symlinks_removed: self.symlinks_removed + other.symlinks_removed,
81            directories_removed: self.directories_removed + other.directories_removed,
82            files_skipped: self.files_skipped + other.files_skipped,
83            symlinks_skipped: self.symlinks_skipped + other.symlinks_skipped,
84            directories_skipped: self.directories_skipped + other.directories_skipped,
85        }
86    }
87}
88
89impl std::fmt::Display for Summary {
90    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
91        write!(
92            f,
93            "bytes removed: {}\n\
94            files removed: {}\n\
95            symlinks removed: {}\n\
96            directories removed: {}\n\
97            files skipped: {}\n\
98            symlinks skipped: {}\n\
99            directories skipped: {}\n",
100            bytesize::ByteSize(self.bytes_removed),
101            self.files_removed,
102            self.symlinks_removed,
103            self.directories_removed,
104            self.files_skipped,
105            self.symlinks_skipped,
106            self.directories_skipped
107        )
108    }
109}
110
111/// RAII guard that restores a directory's original mode on drop.
112///
113/// `rm_internal` chmod-relaxes a read-only directory to `0o777` to clear its contents. If the
114/// directory is retained (filter-protected children, time-filter skip, ENOTEMPTY) or any error
115/// occurs after the relax, the original mode must be restored — otherwise a `--delete`/`rrm`
116/// run leaves a protected tree world-writable. Drop fires on every exit path (retain, error,
117/// panic-unwind) without callers having to remember it; the success-remove path calls
118/// [`Self::defuse`] because the directory no longer exists.
119///
120/// Drop runs synchronously (it can't be async), so it uses `std::fs::set_permissions`: one
121/// chmod syscall — negligible cost, no need to round-trip through the tokio blocking pool just
122/// for cleanup. Best-effort: a restore failure is logged, not fatal.
123struct RelaxedDirGuard<'a> {
124    path: &'a std::path::Path,
125    /// Original mode to restore on drop. `None` means defused (no restore).
126    mode: Option<u32>,
127}
128
129impl<'a> RelaxedDirGuard<'a> {
130    fn new(path: &'a std::path::Path) -> Self {
131        Self { path, mode: None }
132    }
133    /// Record the original mode so it's restored on drop.
134    fn arm(&mut self, original_mode: u32) {
135        self.mode = Some(original_mode);
136    }
137    /// Cancel the pending restore (call after successfully removing the directory).
138    fn defuse(&mut self) {
139        self.mode = None;
140    }
141}
142
143impl Drop for RelaxedDirGuard<'_> {
144    fn drop(&mut self) {
145        if let Some(mode) = self.mode.take()
146            && let Err(err) =
147                std::fs::set_permissions(self.path, std::fs::Permissions::from_mode(mode))
148        {
149            tracing::warn!(
150                "failed to restore original permissions on retained directory {:?}: {:#}",
151                self.path,
152                err
153            );
154        }
155    }
156}
157
158/// Public entry point for remove operations.
159/// Internally delegates to rm_internal with source_root tracking for proper filter matching.
160#[instrument(skip(prog_track, settings))]
161pub async fn rm(
162    prog_track: &'static progress::Progress,
163    path: &std::path::Path,
164    settings: &Settings,
165) -> Result<Summary, Error> {
166    // check filter for top-level path (files, directories, and symlinks)
167    if let Some(ref filter) = settings.filter {
168        let path_name = path.file_name().map(std::path::Path::new);
169        if let Some(name) = path_name {
170            let path_metadata = crate::walk::run_metadata_probed(
171                congestion::Side::Source,
172                congestion::MetadataOp::Stat,
173                tokio::fs::symlink_metadata(path),
174            )
175            .await
176            .with_context(|| format!("failed reading metadata from {:?}", &path))
177            .map_err(|err| Error::new(err, Default::default()))?;
178            let is_dir = path_metadata.is_dir();
179            let result = filter.should_include_root_item(name, is_dir);
180            match result {
181                crate::filter::FilterResult::Included => {}
182                result => {
183                    let kind = EntryKind::from_metadata(&path_metadata);
184                    if let Some(mode) = settings.dry_run {
185                        crate::dry_run::report_skip(path, &result, mode, kind.label_long());
186                    }
187                    kind.inc_skipped(prog_track);
188                    return Ok(skipped_summary_for(kind));
189                }
190            }
191        }
192    }
193    // note: the time filter (applied to files, symlinks, and directories) is handled
194    // inside rm_internal, so we don't duplicate the check here.
195    rm_internal(prog_track, path, path, settings).await
196}
197
198/// Like [`rm`], but evaluates the include/exclude filter relative to `filter_root` instead of
199/// `path`. Used by `--delete` pruning: when removing an extraneous destination subtree,
200/// descendant excludes must be matched against their full destination-root-relative paths
201/// (which mirror the source-relative paths the filter targets), so path/anchored patterns
202/// like `cache/*.log` protect the right entries. The caller is responsible for the top-level
203/// filter decision on `path`.
204pub async fn rm_with_filter_root(
205    prog_track: &'static progress::Progress,
206    path: &std::path::Path,
207    filter_root: &std::path::Path,
208    settings: &Settings,
209) -> Result<Summary, Error> {
210    rm_internal(prog_track, path, filter_root, settings).await
211}
212#[instrument(skip(prog_track, settings))]
213#[async_recursion]
214async fn rm_internal(
215    prog_track: &'static progress::Progress,
216    path: &std::path::Path,
217    source_root: &std::path::Path,
218    settings: &Settings,
219) -> Result<Summary, Error> {
220    let _ops_guard = prog_track.ops.guard();
221    tracing::debug!("read path metadata");
222    let src_metadata = crate::walk::run_metadata_probed(
223        congestion::Side::Source,
224        congestion::MetadataOp::Stat,
225        tokio::fs::symlink_metadata(path),
226    )
227    .await
228    .with_context(|| format!("failed reading metadata from {:?}", &path))
229    .map_err(|err| Error::new(err, Default::default()))?;
230    if !src_metadata.is_dir() {
231        tracing::debug!("not a directory, just remove");
232        let is_symlink = src_metadata.file_type().is_symlink();
233        let file_size = if is_symlink { 0 } else { src_metadata.len() };
234        // apply time filter before removing (files/symlinks only)
235        if let Some(ref time_filter) = settings.time_filter {
236            let entry_type = if is_symlink { "symlink" } else { "file" };
237            let make_skipped_summary = || {
238                tracing::debug!("skipping {:?} due to time filter", &path);
239                if is_symlink {
240                    prog_track.symlinks_skipped.inc();
241                    Summary {
242                        symlinks_skipped: 1,
243                        ..Default::default()
244                    }
245                } else {
246                    prog_track.files_skipped.inc();
247                    Summary {
248                        files_skipped: 1,
249                        ..Default::default()
250                    }
251                }
252            };
253            match time_filter.matches(&src_metadata) {
254                Ok(result) => {
255                    if let Some(skip_reason) = result.as_skip_reason() {
256                        if let Some(mode) = settings.dry_run {
257                            crate::dry_run::report_time_skip(path, skip_reason, mode, entry_type);
258                        }
259                        return Ok(make_skipped_summary());
260                    }
261                }
262                Err(err) => {
263                    let err = err.context(format!("failed evaluating time filter on {:?}", &path));
264                    if settings.fail_early {
265                        return Err(Error::new(err, Default::default()));
266                    }
267                    // log and skip — never delete an entry whose age we cannot verify.
268                    // btime being unsupported (common for symlinks) is expected noise, so
269                    // downgrade to warn; anything else is unexpected and stays at error.
270                    if is_unsupported_io_error(&err) {
271                        tracing::warn!(
272                            "time filter evaluation unsupported for {} {:?}, skipping: {:#}",
273                            entry_type,
274                            &path,
275                            &err
276                        );
277                    } else {
278                        tracing::error!(
279                            "time filter evaluation failed for {} {:?}, skipping: {:#}",
280                            entry_type,
281                            &path,
282                            &err
283                        );
284                    }
285                    return Ok(make_skipped_summary());
286                }
287            }
288        }
289        // handle dry-run mode for files/symlinks
290        if settings.dry_run.is_some() {
291            let entry_type = if is_symlink { "symlink" } else { "file" };
292            crate::dry_run::report_action("remove", path, None, entry_type);
293            return Ok(Summary {
294                bytes_removed: file_size,
295                files_removed: if is_symlink { 0 } else { 1 },
296                symlinks_removed: if is_symlink { 1 } else { 0 },
297                ..Default::default()
298            });
299        }
300        if let Err(err) = crate::walk::run_metadata_probed(
301            congestion::Side::Destination,
302            congestion::MetadataOp::Unlink,
303            tokio::fs::remove_file(path),
304        )
305        .await
306        .with_context(|| format!("failed removing {:?}", &path))
307        {
308            return Err(Error::new(err, Default::default()));
309        }
310        if is_symlink {
311            prog_track.symlinks_removed.inc();
312            return Ok(Summary {
313                symlinks_removed: 1,
314                ..Default::default()
315            });
316        }
317        prog_track.files_removed.inc();
318        prog_track.bytes_removed.add(file_size);
319        return Ok(Summary {
320            bytes_removed: file_size,
321            files_removed: 1,
322            ..Default::default()
323        });
324    }
325    tracing::debug!("remove contents of the directory first");
326    // When the directory is read-only we relax it to 0o777 so its contents can be cleared.
327    // `relaxed_guard` records the original mode and restores it on Drop — covering every
328    // retain branch (filter-protected children, time-filter skip, ENOTEMPTY) AND every error
329    // path that returns before the directory is removed. The success-remove path calls
330    // `defuse()` because the directory no longer exists. Dry-run skips the relax entirely,
331    // so the guard stays unarmed.
332    let mut relaxed_guard = RelaxedDirGuard::new(path);
333    if settings.dry_run.is_none() && src_metadata.permissions().readonly() {
334        tracing::debug!("directory is read-only - change the permissions");
335        let original_mode = src_metadata.permissions().mode() & 0o7777;
336        tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o777))
337            .await
338            .with_context(|| {
339                format!(
340                    "failed to make '{:?}' directory readable and writeable",
341                    &path
342                )
343            })
344            .map_err(|err| Error::new(err, Default::default()))?;
345        relaxed_guard.arm(original_mode);
346    }
347    let mut entries = match tokio::fs::read_dir(path).await {
348        Ok(entries) => entries,
349        Err(err) => {
350            return Err(Error::new(
351                anyhow::Error::new(err).context(format!("failed reading directory {:?}", &path)),
352                Default::default(),
353            ));
354        }
355    };
356    let mut join_set = tokio::task::JoinSet::new();
357    let errors = crate::error_collector::ErrorCollector::default();
358    let mut skipped_files = 0;
359    let mut skipped_symlinks = 0;
360    let mut skipped_dirs = 0;
361    loop {
362        let next_entry =
363            crate::walk::next_entry_probed(&mut entries, congestion::Side::Source, || {
364                format!("failed traversing directory {:?}", &path)
365            })
366            .await;
367        let Some((entry, entry_file_type)) = (match next_entry {
368            Ok(opt) => opt,
369            Err(err) => {
370                return Err(Error::new(err, Default::default()));
371            }
372        }) else {
373            break;
374        };
375        let entry_path = entry.path();
376        let entry_kind = EntryKind::from_file_type(entry_file_type.as_ref());
377        let entry_is_dir = entry_kind == EntryKind::Dir;
378        // compute relative path from source_root for filter matching
379        let relative_path = walk::relative_to_root(&entry_path, source_root);
380        // apply filter if configured
381        if let Some(skip_result) =
382            walk::should_skip_entry(&settings.filter, relative_path, entry_is_dir)
383        {
384            if let Some(mode) = settings.dry_run {
385                crate::dry_run::report_skip(&entry_path, &skip_result, mode, entry_kind.label());
386            }
387            tracing::debug!("skipping {:?} due to filter", &entry_path);
388            // increment skipped counters - will be added to rm_summary below
389            match entry_kind {
390                EntryKind::Dir => skipped_dirs += 1,
391                EntryKind::Symlink => skipped_symlinks += 1,
392                EntryKind::File | EntryKind::Special => skipped_files += 1,
393            }
394            entry_kind.inc_skipped(prog_track);
395            continue;
396        }
397        let settings = settings.clone();
398        let source_root = source_root.to_owned();
399        // for positively-known leaf entries (files, symlinks, special),
400        // acquire the pending-meta permit BEFORE spawning so we don't create
401        // unbounded tasks. We deliberately skip pre-acquire when
402        // `entry_file_type` is None (file_type() lookup failed): the entry
403        // could actually be a directory, and a chain of such unknown-typed
404        // directories holding permits while recursing would deadlock the
405        // pending-meta pool. Directories also skip pre-acquire for the same
406        // reason. We use the pending-meta semaphore (not open-files) because
407        // rm operations don't hold fds — and rm is reachable from copy_file's
408        // overwrite path, which already holds an open-files permit; using a
409        // distinct semaphore avoids that cross-pool deadlock.
410        let known_leaf = entry_file_type.as_ref().is_some_and(|ft| !ft.is_dir());
411        let pending_guard = if known_leaf {
412            Some(throttle::pending_meta_permit().await)
413        } else {
414            None
415        };
416        let do_rm = || async move {
417            let _pending_guard = pending_guard;
418            rm_internal(prog_track, &entry_path, &source_root, &settings).await
419        };
420        join_set.spawn(do_rm());
421    }
422    // unfortunately ReadDir is opening file-descriptors and there's not a good way to limit this,
423    // one thing we CAN do however is to drop it as soon as we're done with it
424    drop(entries);
425    let mut rm_summary = Summary {
426        directories_removed: 0,
427        files_skipped: skipped_files,
428        symlinks_skipped: skipped_symlinks,
429        directories_skipped: skipped_dirs,
430        ..Default::default()
431    };
432    while let Some(res) = join_set.join_next().await {
433        match res {
434            Ok(result) => match result {
435                Ok(summary) => rm_summary = rm_summary + summary,
436                Err(error) => {
437                    tracing::error!("remove: {:?} failed with: {:#}", path, &error);
438                    rm_summary = rm_summary + error.summary;
439                    errors.push(error.source);
440                    if settings.fail_early {
441                        break;
442                    }
443                }
444            },
445            Err(error) => {
446                errors.push(error.into());
447                if settings.fail_early {
448                    break;
449                }
450            }
451        }
452    }
453    if errors.has_errors() {
454        // unwrap is safe: has_errors() guarantees into_error() returns Some
455        return Err(Error::new(errors.into_error().unwrap(), rm_summary));
456    }
457    tracing::debug!("finally remove the empty directory");
458    let anything_removed = rm_summary.files_removed > 0
459        || rm_summary.symlinks_removed > 0
460        || rm_summary.directories_removed > 0;
461    let anything_skipped = rm_summary.files_skipped > 0
462        || rm_summary.symlinks_skipped > 0
463        || rm_summary.directories_skipped > 0;
464    // a directory is "traversed only" when include filters are active, nothing was removed
465    // from it, and the directory itself doesn't directly match an include pattern. such
466    // directories were only entered to search for matching content inside and should be
467    // left intact. directories that directly match an include pattern (e.g. --include target/)
468    // should be removed even if empty. exclude-only filters never produce traversed-only
469    // directories because directly_matches_include returns true when no includes exist.
470    let relative_path = walk::relative_to_root(path, source_root);
471    let traversed_only = !anything_removed
472        && settings
473            .filter
474            .as_ref()
475            .is_some_and(|f| f.has_includes() && !f.directly_matches_include(relative_path, true));
476    // evaluate the directory's own time filter to decide whether to remove it.
477    // the time filter is an entry filter, not a subtree gate: children are already handled
478    // by their own recursive calls, so this decision only controls the final remove_dir.
479    // returns Ok(true) = proceed, Ok(false) = skip (too new), Err propagates a fail-early.
480    // the src_metadata captured at entry is used so rrm's own mutations during traversal
481    // don't change the answer.
482    let dir_passes_time_filter: bool = if let Some(ref time_filter) = settings.time_filter {
483        match time_filter.matches(&src_metadata) {
484            Ok(result) => match result.as_skip_reason() {
485                Some(reason) => {
486                    if let Some(mode) = settings.dry_run {
487                        crate::dry_run::report_time_skip(path, reason, mode, "dir");
488                    }
489                    false
490                }
491                None => true,
492            },
493            Err(err) => {
494                let err = err.context(format!("failed evaluating time filter on {:?}", &path));
495                if settings.fail_early {
496                    return Err(Error::new(err, rm_summary));
497                }
498                // log and skip — never remove a directory whose age we cannot verify.
499                // btime being unsupported on the filesystem is expected noise; downgrade
500                // to warn. anything else is unexpected and stays at error.
501                if is_unsupported_io_error(&err) {
502                    tracing::warn!(
503                        "time filter evaluation unsupported for dir {:?}, leaving it intact: {:#}",
504                        &path,
505                        &err
506                    );
507                } else {
508                    tracing::error!(
509                        "time filter evaluation failed for dir {:?}, leaving it intact: {:#}",
510                        &path,
511                        &err
512                    );
513                }
514                false
515            }
516        }
517    } else {
518        true
519    };
520    // handle dry-run mode for directories.
521    // `traversed_only` catches dirs only entered to search for include pattern matches.
522    // `anything_skipped` catches dirs that would still have content after partial removal.
523    // `!dir_passes_time_filter` catches dirs whose own timestamps disqualify removal.
524    // the real-mode path below only needs `traversed_only` and `!dir_passes_time_filter`
525    // because the subsequent `remove_dir` call handles the non-empty case via ENOTEMPTY.
526    if settings.dry_run.is_some() {
527        if traversed_only || anything_skipped || !dir_passes_time_filter {
528            tracing::debug!(
529                "dry-run: directory {:?} would not be removed (removed={}, skipped={}, time_ok={})",
530                &path,
531                anything_removed,
532                anything_skipped,
533                dir_passes_time_filter
534            );
535            if !dir_passes_time_filter {
536                prog_track.directories_skipped.inc();
537                rm_summary.directories_skipped += 1;
538            }
539        } else {
540            crate::dry_run::report_action("remove", path, None, "dir");
541            rm_summary.directories_removed += 1;
542        }
543        return Ok(rm_summary);
544    }
545    // skip directories that were only traversed to look for include matches.
546    // not needed for exclude-only filters or directly-matched directories.
547    // non-empty directories are handled by the ENOTEMPTY check below.
548    if traversed_only {
549        tracing::debug!(
550            "directory {:?} had nothing removed, leaving it intact",
551            &path
552        );
553        return Ok(rm_summary);
554    }
555    // skip directories whose own timestamps don't satisfy the time filter.
556    // children have already been processed; this only gates the dir's own removal.
557    if !dir_passes_time_filter {
558        tracing::debug!(
559            "directory {:?} skipped by time filter, leaving it intact",
560            &path
561        );
562        prog_track.directories_skipped.inc();
563        rm_summary.directories_skipped += 1;
564        return Ok(rm_summary);
565    }
566    // when filtering is active, directories may not be empty because we only removed
567    // matching files (includes) or skipped excluded files; use remove_dir (not remove_dir_all)
568    // so non-empty directories fail gracefully with ENOTEMPTY
569    let any_filter_active = settings.filter.is_some() || settings.time_filter.is_some();
570    match crate::walk::run_metadata_probed(
571        congestion::Side::Destination,
572        congestion::MetadataOp::RmDir,
573        tokio::fs::remove_dir(path),
574    )
575    .await
576    {
577        Ok(()) => {
578            prog_track.directories_removed.inc();
579            rm_summary.directories_removed += 1;
580            // the directory is gone, so there's nothing to restore on drop.
581            relaxed_guard.defuse();
582        }
583        Err(err) if any_filter_active => {
584            // with filtering, it's expected that directories may not be empty because we only
585            // removed matching files; raw_os_error 39 is ENOTEMPTY on Linux. this is not an
586            // error — surface it at info so users can see which directories survived.
587            if err.kind() == std::io::ErrorKind::DirectoryNotEmpty || err.raw_os_error() == Some(39)
588            {
589                tracing::info!(
590                    "directory {:?} not empty after filtering, leaving it intact",
591                    &path
592                );
593            } else {
594                return Err(Error::new(
595                    anyhow!(err).context(format!("failed removing directory {:?}", &path)),
596                    rm_summary,
597                ));
598            }
599        }
600        Err(err) => {
601            return Err(Error::new(
602                anyhow!(err).context(format!("failed removing directory {:?}", &path)),
603                rm_summary,
604            ));
605        }
606    }
607    Ok(rm_summary)
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use crate::config::DryRunMode;
614    use crate::testutils;
615    use tracing_test::traced_test;
616
617    static PROGRESS: std::sync::LazyLock<progress::Progress> =
618        std::sync::LazyLock::new(progress::Progress::new);
619
620    #[tokio::test]
621    #[traced_test]
622    async fn no_write_permission() -> Result<(), anyhow::Error> {
623        let tmp_dir = testutils::setup_test_dir().await?;
624        let test_path = tmp_dir.as_path();
625        let filepaths = vec![
626            test_path.join("foo").join("0.txt"),
627            test_path.join("foo").join("bar").join("2.txt"),
628            test_path.join("foo").join("baz").join("4.txt"),
629            test_path.join("foo").join("baz"),
630        ];
631        for fpath in &filepaths {
632            // change file permissions to not readable and not writable
633            tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o555)).await?;
634        }
635        let summary = rm(
636            &PROGRESS,
637            &test_path.join("foo"),
638            &Settings {
639                fail_early: false,
640                filter: None,
641                dry_run: None,
642                time_filter: None,
643            },
644        )
645        .await?;
646        assert!(!test_path.join("foo").exists());
647        assert_eq!(summary.files_removed, 5);
648        assert_eq!(summary.symlinks_removed, 2);
649        assert_eq!(summary.directories_removed, 3);
650        Ok(())
651    }
652
653    #[tokio::test]
654    #[traced_test]
655    async fn relaxed_dir_mode_restored_on_error_exit() -> Result<(), anyhow::Error> {
656        // Regression: when rm_internal chmod-relaxes a read-only directory to 0o777 to clear
657        // its contents, it must restore the original mode on ERROR paths too — not just on the
658        // retain paths (traversed-only, time-filtered skip, ENOTEMPTY under filter). Without
659        // that, a partial failure leaves the directory world-writable.
660        //
661        // We trigger the remove_dir error path by making the dst's PARENT non-writable: rm can
662        // chmod-relax dst and successfully unlink its contents (write on dst is granted by the
663        // relax), but the final remove_dir(dst) needs write permission on the parent, which it
664        // doesn't have → EACCES. The fix's inline restore at that error site must put dst back
665        // to 0o555 before propagating.
666        let tmp = tempfile::tempdir()?;
667        let parent = tmp.path().join("parent");
668        let dst = parent.join("dst");
669        tokio::fs::create_dir(&parent).await?;
670        tokio::fs::create_dir(&dst).await?;
671        tokio::fs::write(dst.join("inside.txt"), b"x").await?;
672        // dst is read-only → rm relaxes it.
673        tokio::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o555)).await?;
674        // parent is read-only (still traversable via the execute bit) → remove_dir(dst) fails.
675        tokio::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o555)).await?;
676
677        let result = rm(
678            &PROGRESS,
679            &dst,
680            &Settings {
681                fail_early: false,
682                filter: None,
683                dry_run: None,
684                time_filter: None,
685            },
686        )
687        .await;
688
689        // restore parent writability so we can stat dst and clean up regardless of the assertion.
690        tokio::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).await?;
691
692        assert!(
693            result.is_err(),
694            "rm must fail when its dst can be emptied but the parent dir blocks remove_dir"
695        );
696        let mode = tokio::fs::metadata(&dst).await?.permissions().mode() & 0o7777;
697        assert_eq!(
698            mode, 0o555,
699            "relaxed-then-erroring directory must be restored to its original mode (got {mode:o}o); leaving it 0o777 leaks permissions on partial failure"
700        );
701
702        // cleanup
703        tokio::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o755)).await?;
704        Ok(())
705    }
706
707    #[tokio::test]
708    #[traced_test]
709    async fn parent_dir_no_write_permission() -> Result<(), anyhow::Error> {
710        let tmp_dir = testutils::setup_test_dir().await?;
711        let test_path = tmp_dir.as_path();
712        // make parent directory read-only (no write permission)
713        tokio::fs::set_permissions(
714            &test_path.join("foo").join("bar"),
715            std::fs::Permissions::from_mode(0o555),
716        )
717        .await?;
718        let result = rm(
719            &PROGRESS,
720            &test_path.join("foo").join("bar").join("2.txt"),
721            &Settings {
722                fail_early: true,
723                filter: None,
724                dry_run: None,
725                time_filter: None,
726            },
727        )
728        .await;
729        // should fail with permission denied error
730        assert!(result.is_err());
731        let err = result.unwrap_err();
732        let err_string = format!("{:#}", err);
733        // verify the error chain includes "Permission denied"
734        assert!(
735            err_string.contains("Permission denied") || err_string.contains("permission denied"),
736            "Error should contain 'Permission denied' but got: {}",
737            err_string
738        );
739        Ok(())
740    }
741
742    mod filter_tests {
743        use super::*;
744        use crate::filter::FilterSettings;
745        /// Test that path-based patterns (with /) work correctly with nested paths.
746        #[tokio::test]
747        #[traced_test]
748        async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
749            let tmp_dir = testutils::setup_test_dir().await?;
750            let test_path = tmp_dir.as_path();
751            // create filter that should only remove files in bar/ directory
752            let mut filter = FilterSettings::new();
753            filter.add_include("bar/*.txt").unwrap();
754            let summary = rm(
755                &PROGRESS,
756                &test_path.join("foo"),
757                &Settings {
758                    fail_early: false,
759                    filter: Some(filter),
760                    dry_run: None,
761                    time_filter: None,
762                },
763            )
764            .await?;
765            // should only remove files matching bar/*.txt pattern (bar/1.txt, bar/2.txt, bar/3.txt)
766            assert_eq!(
767                summary.files_removed, 3,
768                "should remove 3 files matching bar/*.txt"
769            );
770            // each file is 1 byte ("1", "2", "3")
771            assert_eq!(summary.bytes_removed, 3, "should report 3 bytes removed");
772            // verify the right files were removed
773            assert!(
774                !test_path.join("foo/bar/1.txt").exists(),
775                "bar/1.txt should be removed"
776            );
777            assert!(
778                !test_path.join("foo/bar/2.txt").exists(),
779                "bar/2.txt should be removed"
780            );
781            assert!(
782                !test_path.join("foo/bar/3.txt").exists(),
783                "bar/3.txt should be removed"
784            );
785            // verify files outside the pattern still exist
786            assert!(
787                test_path.join("foo/0.txt").exists(),
788                "0.txt should still exist"
789            );
790            Ok(())
791        }
792        /// Test that filters are applied to top-level file arguments.
793        #[tokio::test]
794        #[traced_test]
795        async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
796            let tmp_dir = testutils::setup_test_dir().await?;
797            let test_path = tmp_dir.as_path();
798            // create filter that excludes .txt files
799            let mut filter = FilterSettings::new();
800            filter.add_exclude("*.txt").unwrap();
801            let summary = rm(
802                &PROGRESS,
803                &test_path.join("foo/0.txt"), // single file source
804                &Settings {
805                    fail_early: false,
806                    filter: Some(filter),
807                    dry_run: None,
808                    time_filter: None,
809                },
810            )
811            .await?;
812            // the file should NOT be removed because it matches the exclude pattern
813            assert_eq!(
814                summary.files_removed, 0,
815                "file matching exclude pattern should not be removed"
816            );
817            assert!(
818                test_path.join("foo/0.txt").exists(),
819                "excluded file should still exist"
820            );
821            Ok(())
822        }
823        /// Test that filters apply to root directories with simple exclude patterns.
824        #[tokio::test]
825        #[traced_test]
826        async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
827            let test_path = testutils::create_temp_dir().await?;
828            // create a directory that should be excluded
829            tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
830            tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
831            // create filter that excludes *_dir/ directories
832            let mut filter = FilterSettings::new();
833            filter.add_exclude("*_dir/").unwrap();
834            let result = rm(
835                &PROGRESS,
836                &test_path.join("excluded_dir"),
837                &Settings {
838                    fail_early: false,
839                    filter: Some(filter),
840                    dry_run: None,
841                    time_filter: None,
842                },
843            )
844            .await?;
845            // directory should NOT be removed because it matches exclude pattern
846            assert_eq!(
847                result.directories_removed, 0,
848                "root directory matching exclude should not be removed"
849            );
850            assert!(
851                test_path.join("excluded_dir").exists(),
852                "excluded root directory should still exist"
853            );
854            Ok(())
855        }
856        /// Test that filters apply to root symlinks with simple exclude patterns.
857        #[tokio::test]
858        #[traced_test]
859        async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
860            let test_path = testutils::create_temp_dir().await?;
861            // create a target file and a symlink to it
862            tokio::fs::write(test_path.join("target.txt"), "content").await?;
863            tokio::fs::symlink(
864                test_path.join("target.txt"),
865                test_path.join("excluded_link"),
866            )
867            .await?;
868            // create filter that excludes *_link
869            let mut filter = FilterSettings::new();
870            filter.add_exclude("*_link").unwrap();
871            let result = rm(
872                &PROGRESS,
873                &test_path.join("excluded_link"),
874                &Settings {
875                    fail_early: false,
876                    filter: Some(filter),
877                    dry_run: None,
878                    time_filter: None,
879                },
880            )
881            .await?;
882            // symlink should NOT be removed because it matches exclude pattern
883            assert_eq!(
884                result.symlinks_removed, 0,
885                "root symlink matching exclude should not be removed"
886            );
887            assert!(
888                test_path.join("excluded_link").exists(),
889                "excluded root symlink should still exist"
890            );
891            Ok(())
892        }
893        /// Test combined include and exclude patterns (exclude takes precedence).
894        #[tokio::test]
895        #[traced_test]
896        async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
897            let tmp_dir = testutils::setup_test_dir().await?;
898            let test_path = tmp_dir.as_path();
899            // test structure from setup_test_dir:
900            // foo/
901            //   0.txt
902            //   bar/ (1.txt, 2.txt, 3.txt)
903            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
904            // include all .txt files in bar/, but exclude 2.txt specifically
905            let mut filter = FilterSettings::new();
906            filter.add_include("bar/*.txt").unwrap();
907            filter.add_exclude("bar/2.txt").unwrap();
908            let summary = rm(
909                &PROGRESS,
910                &test_path.join("foo"),
911                &Settings {
912                    fail_early: false,
913                    filter: Some(filter),
914                    dry_run: None,
915                    time_filter: None,
916                },
917            )
918            .await?;
919            // should remove: bar/1.txt, bar/3.txt = 2 files
920            // should skip: bar/2.txt (excluded by pattern), 0.txt (excluded by default - no match) = 2 files
921            assert_eq!(summary.files_removed, 2, "should remove 2 files");
922            assert_eq!(
923                summary.files_skipped, 2,
924                "should skip 2 files (bar/2.txt excluded, 0.txt no match)"
925            );
926            // verify
927            assert!(
928                !test_path.join("foo/bar/1.txt").exists(),
929                "bar/1.txt should be removed"
930            );
931            assert!(
932                test_path.join("foo/bar/2.txt").exists(),
933                "bar/2.txt should be excluded"
934            );
935            assert!(
936                !test_path.join("foo/bar/3.txt").exists(),
937                "bar/3.txt should be removed"
938            );
939            Ok(())
940        }
941        /// Test that skipped counts accurately reflect what was filtered.
942        #[tokio::test]
943        #[traced_test]
944        async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
945            let tmp_dir = testutils::setup_test_dir().await?;
946            let test_path = tmp_dir.as_path();
947            // test structure from setup_test_dir:
948            // foo/
949            //   0.txt
950            //   bar/ (1.txt, 2.txt, 3.txt)
951            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
952            // exclude bar/ directory entirely
953            let mut filter = FilterSettings::new();
954            filter.add_exclude("bar/").unwrap();
955            let summary = rm(
956                &PROGRESS,
957                &test_path.join("foo"),
958                &Settings {
959                    fail_early: false,
960                    filter: Some(filter),
961                    dry_run: None,
962                    time_filter: None,
963                },
964            )
965            .await?;
966            // removed: 0.txt, baz/4.txt = 2 files
967            // removed: baz/5.txt symlink, baz/6.txt symlink = 2 symlinks
968            // removed: baz = 1 directory (foo cannot be removed because bar still exists)
969            // skipped: bar directory (1 dir) - contents not counted since whole dir skipped
970            assert_eq!(summary.files_removed, 2, "should remove 2 files");
971            assert_eq!(summary.symlinks_removed, 2, "should remove 2 symlinks");
972            assert_eq!(
973                summary.directories_removed, 1,
974                "should remove 1 directory (baz only, foo not empty)"
975            );
976            assert_eq!(
977                summary.directories_skipped, 1,
978                "should skip 1 directory (bar)"
979            );
980            // bar should still exist
981            assert!(
982                test_path.join("foo/bar").exists(),
983                "bar directory should still exist"
984            );
985            // foo should still exist (not empty because bar is still there)
986            assert!(
987                test_path.join("foo").exists(),
988                "foo directory should still exist (contains bar)"
989            );
990            Ok(())
991        }
992        /// Test that empty directories are not removed when they were only traversed to look
993        /// for matches (regression test for bug where --include='foo' would remove empty dir baz).
994        #[tokio::test]
995        #[traced_test]
996        async fn test_empty_dir_not_removed_when_only_traversed() -> Result<(), anyhow::Error> {
997            let test_path = testutils::create_temp_dir().await?;
998            // create structure:
999            // test/
1000            //   foo (file)
1001            //   bar (file)
1002            //   baz/ (empty directory)
1003            tokio::fs::write(test_path.join("foo"), "content").await?;
1004            tokio::fs::write(test_path.join("bar"), "content").await?;
1005            tokio::fs::create_dir(test_path.join("baz")).await?;
1006            // include only 'foo' file
1007            let mut filter = FilterSettings::new();
1008            filter.add_include("foo").unwrap();
1009            let summary = rm(
1010                &PROGRESS,
1011                &test_path,
1012                &Settings {
1013                    fail_early: false,
1014                    filter: Some(filter),
1015                    dry_run: None,
1016                    time_filter: None,
1017                },
1018            )
1019            .await?;
1020            // only 'foo' should be removed
1021            assert_eq!(summary.files_removed, 1, "should remove only 'foo' file");
1022            assert_eq!(
1023                summary.directories_removed, 0,
1024                "should NOT remove empty 'baz' directory"
1025            );
1026            // verify foo was removed
1027            assert!(!test_path.join("foo").exists(), "foo should be removed");
1028            // verify bar still exists (not matching include pattern)
1029            assert!(test_path.join("bar").exists(), "bar should still exist");
1030            // verify empty baz directory still exists
1031            assert!(
1032                test_path.join("baz").exists(),
1033                "empty baz directory should NOT be removed"
1034            );
1035            Ok(())
1036        }
1037        /// Test that empty directories ARE removed with exclude-only filters.
1038        /// Unlike include filters (where empty dirs are only traversed for matches),
1039        /// exclude-only filters should not prevent removal of empty directories.
1040        #[tokio::test]
1041        #[traced_test]
1042        async fn test_exclude_only_removes_empty_directory() -> Result<(), anyhow::Error> {
1043            let test_path = testutils::create_temp_dir().await?;
1044            // create structure:
1045            // test/
1046            //   foo (file)
1047            //   bar.log (file)
1048            //   baz/ (empty directory)
1049            tokio::fs::write(test_path.join("foo"), "content").await?;
1050            tokio::fs::write(test_path.join("bar.log"), "content").await?;
1051            tokio::fs::create_dir(test_path.join("baz")).await?;
1052            // exclude only .log files
1053            let mut filter = FilterSettings::new();
1054            filter.add_exclude("*.log").unwrap();
1055            let summary = rm(
1056                &PROGRESS,
1057                &test_path,
1058                &Settings {
1059                    fail_early: false,
1060                    filter: Some(filter),
1061                    dry_run: None,
1062                    time_filter: None,
1063                },
1064            )
1065            .await?;
1066            // foo should be removed, bar.log should be skipped, baz/ should be removed
1067            assert_eq!(summary.files_removed, 1, "should remove 'foo'");
1068            assert_eq!(summary.files_skipped, 1, "should skip 'bar.log'");
1069            assert_eq!(
1070                summary.directories_removed, 1,
1071                "should remove empty 'baz' directory"
1072            );
1073            assert!(!test_path.join("foo").exists(), "foo should be removed");
1074            assert!(
1075                test_path.join("bar.log").exists(),
1076                "bar.log should still exist"
1077            );
1078            assert!(
1079                !test_path.join("baz").exists(),
1080                "empty baz directory should be removed"
1081            );
1082            Ok(())
1083        }
1084        /// Test that empty directories are not removed in dry-run mode when only traversed.
1085        #[tokio::test]
1086        #[traced_test]
1087        async fn test_dry_run_empty_dir_not_reported_as_removed() -> Result<(), anyhow::Error> {
1088            let test_path = testutils::create_temp_dir().await?;
1089            // create structure:
1090            // test/
1091            //   foo (file)
1092            //   bar (file)
1093            //   baz/ (empty directory)
1094            tokio::fs::write(test_path.join("foo"), "content").await?;
1095            tokio::fs::write(test_path.join("bar"), "content").await?;
1096            tokio::fs::create_dir(test_path.join("baz")).await?;
1097            // include only 'foo' file
1098            let mut filter = FilterSettings::new();
1099            filter.add_include("foo").unwrap();
1100            let summary = rm(
1101                &PROGRESS,
1102                &test_path,
1103                &Settings {
1104                    fail_early: false,
1105                    filter: Some(filter),
1106                    dry_run: Some(DryRunMode::Explain),
1107                    time_filter: None,
1108                },
1109            )
1110            .await?;
1111            // only 'foo' should be reported as would-be-removed
1112            assert_eq!(
1113                summary.files_removed, 1,
1114                "should report only 'foo' would be removed"
1115            );
1116            assert_eq!(
1117                summary.directories_removed, 0,
1118                "should NOT report empty 'baz' would be removed"
1119            );
1120            // verify nothing was actually removed (dry-run mode)
1121            assert!(test_path.join("foo").exists(), "foo should still exist");
1122            assert!(test_path.join("bar").exists(), "bar should still exist");
1123            assert!(test_path.join("baz").exists(), "baz should still exist");
1124            Ok(())
1125        }
1126        /// Test that an empty directory directly matching an include pattern IS removed.
1127        /// Unlike traversed-only directories, directly matched ones are explicit targets.
1128        #[tokio::test]
1129        #[traced_test]
1130        async fn test_include_directly_matched_empty_dir_is_removed() -> Result<(), anyhow::Error> {
1131            let test_path = testutils::create_temp_dir().await?;
1132            // create structure:
1133            // test/
1134            //   foo (file)
1135            //   baz/ (empty directory)
1136            tokio::fs::write(test_path.join("foo"), "content").await?;
1137            tokio::fs::create_dir(test_path.join("baz")).await?;
1138            // include pattern that directly matches the directory
1139            let mut filter = FilterSettings::new();
1140            filter.add_include("baz/").unwrap();
1141            let summary = rm(
1142                &PROGRESS,
1143                &test_path,
1144                &Settings {
1145                    fail_early: false,
1146                    filter: Some(filter),
1147                    dry_run: None,
1148                    time_filter: None,
1149                },
1150            )
1151            .await?;
1152            assert_eq!(
1153                summary.directories_removed, 1,
1154                "should remove directly matched empty 'baz' directory"
1155            );
1156            assert_eq!(summary.files_removed, 0, "should not remove 'foo'");
1157            assert!(test_path.join("foo").exists(), "foo should still exist");
1158            assert!(
1159                !test_path.join("baz").exists(),
1160                "directly matched empty baz directory should be removed"
1161            );
1162            Ok(())
1163        }
1164    }
1165    mod dry_run_tests {
1166        use super::*;
1167        use crate::filter::FilterSettings;
1168        /// Test that dry-run mode doesn't modify permissions on read-only directories.
1169        #[tokio::test]
1170        #[traced_test]
1171        async fn test_dry_run_preserves_readonly_permissions() -> Result<(), anyhow::Error> {
1172            let tmp_dir = testutils::setup_test_dir().await?;
1173            let test_path = tmp_dir.as_path();
1174            let readonly_dir = test_path.join("foo/bar");
1175            // make the directory read-only
1176            tokio::fs::set_permissions(&readonly_dir, std::fs::Permissions::from_mode(0o555))
1177                .await?;
1178            // verify it's read-only
1179            let before_mode = tokio::fs::metadata(&readonly_dir)
1180                .await?
1181                .permissions()
1182                .mode()
1183                & 0o777;
1184            assert_eq!(
1185                before_mode, 0o555,
1186                "directory should be read-only before dry-run"
1187            );
1188            let summary = rm(
1189                &PROGRESS,
1190                &readonly_dir,
1191                &Settings {
1192                    fail_early: false,
1193                    filter: None,
1194                    dry_run: Some(DryRunMode::Brief),
1195                    time_filter: None,
1196                },
1197            )
1198            .await?;
1199            // verify the directory still exists (dry-run shouldn't remove it)
1200            assert!(
1201                readonly_dir.exists(),
1202                "directory should still exist after dry-run"
1203            );
1204            // verify permissions weren't changed
1205            let after_mode = tokio::fs::metadata(&readonly_dir)
1206                .await?
1207                .permissions()
1208                .mode()
1209                & 0o777;
1210            assert_eq!(
1211                after_mode, 0o555,
1212                "dry-run should not modify directory permissions"
1213            );
1214            // verify summary shows what would be removed
1215            assert!(
1216                summary.directories_removed > 0 || summary.files_removed > 0,
1217                "dry-run should report what would be removed"
1218            );
1219            Ok(())
1220        }
1221        /// Test that dry-run mode with filtering correctly handles directories that
1222        /// wouldn't be empty after filtering.
1223        #[tokio::test]
1224        #[traced_test]
1225        async fn test_dry_run_with_filter_non_empty_directory() -> Result<(), anyhow::Error> {
1226            let tmp_dir = testutils::setup_test_dir().await?;
1227            let test_path = tmp_dir.as_path();
1228            // test structure from setup_test_dir:
1229            // foo/
1230            //   0.txt
1231            //   bar/ (1.txt, 2.txt, 3.txt)
1232            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
1233            // exclude bar/ - so foo would not be empty after removing (bar still there)
1234            let mut filter = crate::filter::FilterSettings::new();
1235            filter.add_exclude("bar/").unwrap();
1236            let summary = rm(
1237                &PROGRESS,
1238                &test_path.join("foo"),
1239                &Settings {
1240                    fail_early: false,
1241                    filter: Some(filter),
1242                    dry_run: Some(DryRunMode::Brief),
1243                    time_filter: None,
1244                },
1245            )
1246            .await?;
1247            // dry-run shouldn't actually remove anything
1248            assert!(
1249                test_path.join("foo").exists(),
1250                "foo should still exist after dry-run"
1251            );
1252            // verify summary reflects what WOULD happen:
1253            // - files: 0.txt, baz/4.txt would be removed = 2
1254            // - symlinks: baz/5.txt, baz/6.txt would be removed = 2
1255            // - directories: baz would be removed, but NOT foo (bar is skipped, so foo not empty)
1256            // - skipped: bar directory = 1
1257            assert_eq!(
1258                summary.files_removed, 2,
1259                "should report 2 files would be removed"
1260            );
1261            assert_eq!(
1262                summary.symlinks_removed, 2,
1263                "should report 2 symlinks would be removed"
1264            );
1265            assert_eq!(
1266                summary.directories_removed, 1,
1267                "should report only baz (not foo) would be removed"
1268            );
1269            assert_eq!(
1270                summary.directories_skipped, 1,
1271                "should report bar directory skipped"
1272            );
1273            Ok(())
1274        }
1275        /// Test that dry-run with exclude-only filter correctly reports empty directories
1276        /// as would-be-removed (unlike include filters where empty dirs are only traversed).
1277        #[tokio::test]
1278        #[traced_test]
1279        async fn test_dry_run_exclude_only_reports_empty_dir_removed() -> Result<(), anyhow::Error>
1280        {
1281            let test_path = testutils::create_temp_dir().await?;
1282            // create structure:
1283            // test/
1284            //   foo (file)
1285            //   bar.log (file)
1286            //   baz/ (empty directory)
1287            tokio::fs::write(test_path.join("foo"), "content").await?;
1288            tokio::fs::write(test_path.join("bar.log"), "content").await?;
1289            tokio::fs::create_dir(test_path.join("baz")).await?;
1290            // exclude only .log files
1291            let mut filter = FilterSettings::new();
1292            filter.add_exclude("*.log").unwrap();
1293            let summary = rm(
1294                &PROGRESS,
1295                &test_path,
1296                &Settings {
1297                    fail_early: false,
1298                    filter: Some(filter),
1299                    dry_run: Some(DryRunMode::Explain),
1300                    time_filter: None,
1301                },
1302            )
1303            .await?;
1304            // foo should be reported as would-be-removed, bar.log skipped, baz/ removed
1305            assert_eq!(
1306                summary.files_removed, 1,
1307                "should report 'foo' would be removed"
1308            );
1309            assert_eq!(
1310                summary.files_skipped, 1,
1311                "should report 'bar.log' would be skipped"
1312            );
1313            assert_eq!(
1314                summary.directories_removed, 1,
1315                "should report empty 'baz' directory would be removed"
1316            );
1317            // verify nothing was actually removed (dry-run mode)
1318            assert!(test_path.join("foo").exists(), "foo should still exist");
1319            assert!(
1320                test_path.join("bar.log").exists(),
1321                "bar.log should still exist"
1322            );
1323            assert!(test_path.join("baz").exists(), "baz should still exist");
1324            Ok(())
1325        }
1326        /// Test that dry-run correctly reports removal of an empty directory that directly
1327        /// matches an include pattern (not merely traversed).
1328        #[tokio::test]
1329        #[traced_test]
1330        async fn test_dry_run_include_directly_matched_empty_dir_reported()
1331        -> Result<(), anyhow::Error> {
1332            let test_path = testutils::create_temp_dir().await?;
1333            // create structure:
1334            // test/
1335            //   foo (file)
1336            //   baz/ (empty directory)
1337            tokio::fs::write(test_path.join("foo"), "content").await?;
1338            tokio::fs::create_dir(test_path.join("baz")).await?;
1339            // include pattern that directly matches the directory
1340            let mut filter = FilterSettings::new();
1341            filter.add_include("baz/").unwrap();
1342            let summary = rm(
1343                &PROGRESS,
1344                &test_path,
1345                &Settings {
1346                    fail_early: false,
1347                    filter: Some(filter),
1348                    dry_run: Some(DryRunMode::Explain),
1349                    time_filter: None,
1350                },
1351            )
1352            .await?;
1353            assert_eq!(
1354                summary.directories_removed, 1,
1355                "should report directly matched empty 'baz' would be removed"
1356            );
1357            assert_eq!(summary.files_removed, 0, "should not report 'foo'");
1358            // verify nothing was actually removed (dry-run mode)
1359            assert!(test_path.join("foo").exists(), "foo should still exist");
1360            assert!(test_path.join("baz").exists(), "baz should still exist");
1361            Ok(())
1362        }
1363    }
1364    mod time_filter_tests {
1365        use super::*;
1366        use crate::filter::TimeFilter;
1367
1368        fn set_mtime_age(path: &std::path::Path, age: std::time::Duration) -> anyhow::Result<()> {
1369            let past = filetime::FileTime::from_system_time(std::time::SystemTime::now() - age);
1370            filetime::set_file_mtime(path, past)?;
1371            Ok(())
1372        }
1373
1374        /// File with mtime older than threshold is removed.
1375        #[tokio::test]
1376        #[traced_test]
1377        async fn removes_files_older_than_modified_before() -> Result<(), anyhow::Error> {
1378            let test_path = testutils::create_temp_dir().await?;
1379            let file = test_path.join("old.txt");
1380            tokio::fs::write(&file, "x").await?;
1381            set_mtime_age(&file, std::time::Duration::from_secs(7200))?;
1382            // age test_path so the root dir passes its own time filter check
1383            set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1384            let summary = rm(
1385                &PROGRESS,
1386                &test_path,
1387                &Settings {
1388                    fail_early: false,
1389                    filter: None,
1390                    time_filter: Some(TimeFilter {
1391                        modified_before: Some(std::time::Duration::from_secs(3600)),
1392                        created_before: None,
1393                    }),
1394                    dry_run: None,
1395                },
1396            )
1397            .await?;
1398            assert_eq!(summary.files_removed, 1, "old file should be removed");
1399            assert_eq!(summary.files_skipped, 0);
1400            assert!(!file.exists(), "old.txt should be removed");
1401            Ok(())
1402        }
1403
1404        /// File with mtime newer than threshold is skipped.
1405        #[tokio::test]
1406        #[traced_test]
1407        async fn keeps_files_newer_than_modified_before() -> Result<(), anyhow::Error> {
1408            let test_path = testutils::create_temp_dir().await?;
1409            let file = test_path.join("new.txt");
1410            tokio::fs::write(&file, "x").await?;
1411            set_mtime_age(&file, std::time::Duration::from_secs(60))?;
1412            set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1413            let summary = rm(
1414                &PROGRESS,
1415                &test_path,
1416                &Settings {
1417                    fail_early: false,
1418                    filter: None,
1419                    time_filter: Some(TimeFilter {
1420                        modified_before: Some(std::time::Duration::from_secs(3600)),
1421                        created_before: None,
1422                    }),
1423                    dry_run: None,
1424                },
1425            )
1426            .await?;
1427            assert_eq!(summary.files_removed, 0, "new file should not be removed");
1428            assert_eq!(summary.files_skipped, 1, "new file should be skipped");
1429            assert!(file.exists(), "new.txt should still exist");
1430            Ok(())
1431        }
1432
1433        /// A fresh subdirectory is descended into (children are handled individually),
1434        /// but the fresh_dir itself is not removed because its own mtime is too recent.
1435        #[tokio::test]
1436        #[traced_test]
1437        async fn fresh_subdirectory_is_descended_but_not_removed() -> Result<(), anyhow::Error> {
1438            let test_path = testutils::create_temp_dir().await?;
1439            let old_file = test_path.join("old.txt");
1440            let fresh_dir = test_path.join("fresh_dir");
1441            let fresh_child = fresh_dir.join("fresh_child.txt");
1442            let old_child = fresh_dir.join("old_child.txt");
1443            tokio::fs::write(&old_file, "x").await?;
1444            tokio::fs::create_dir(&fresh_dir).await?;
1445            tokio::fs::write(&fresh_child, "x").await?;
1446            tokio::fs::write(&old_child, "x").await?;
1447            set_mtime_age(&old_file, std::time::Duration::from_secs(7200))?;
1448            set_mtime_age(&old_child, std::time::Duration::from_secs(7200))?;
1449            // fresh_child keeps its recent mtime; so does fresh_dir (we took the mtime
1450            // snapshot before remove_file mutates it)
1451            set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1452            let summary = rm(
1453                &PROGRESS,
1454                &test_path,
1455                &Settings {
1456                    fail_early: false,
1457                    filter: None,
1458                    time_filter: Some(TimeFilter {
1459                        modified_before: Some(std::time::Duration::from_secs(3600)),
1460                        created_before: None,
1461                    }),
1462                    dry_run: None,
1463                },
1464            )
1465            .await?;
1466            // we descend into fresh_dir: old_child removed, fresh_child skipped
1467            assert_eq!(summary.files_removed, 2, "old.txt and old_child removed");
1468            assert_eq!(
1469                summary.files_skipped, 1,
1470                "fresh_child skipped inside fresh_dir"
1471            );
1472            assert_eq!(
1473                summary.directories_skipped, 1,
1474                "fresh_dir itself is skipped at removal time"
1475            );
1476            assert_eq!(
1477                summary.directories_removed, 0,
1478                "root survives because fresh_dir is still inside it"
1479            );
1480            assert!(!old_file.exists());
1481            assert!(!old_child.exists(), "old_child inside fresh_dir removed");
1482            assert!(
1483                fresh_dir.exists(),
1484                "fresh_dir kept despite its old child being removed"
1485            );
1486            assert!(fresh_child.exists(), "fresh_child inside fresh_dir kept");
1487            Ok(())
1488        }
1489
1490        /// An old directory that still holds a new (skipped) file survives as non-empty.
1491        /// The leftover-dir case is not treated as an error.
1492        #[tokio::test]
1493        #[traced_test]
1494        async fn old_dir_with_new_file_leaves_non_empty_dir_without_error()
1495        -> Result<(), anyhow::Error> {
1496            let test_path = testutils::create_temp_dir().await?;
1497            let old_dir = test_path.join("old_dir");
1498            tokio::fs::create_dir(&old_dir).await?;
1499            let new_file = old_dir.join("new.txt");
1500            tokio::fs::write(&new_file, "x").await?;
1501            set_mtime_age(&new_file, std::time::Duration::from_secs(60))?;
1502            set_mtime_age(&old_dir, std::time::Duration::from_secs(7200))?;
1503            set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1504            let result = rm(
1505                &PROGRESS,
1506                &test_path,
1507                &Settings {
1508                    fail_early: false,
1509                    filter: None,
1510                    time_filter: Some(TimeFilter {
1511                        modified_before: Some(std::time::Duration::from_secs(3600)),
1512                        created_before: None,
1513                    }),
1514                    dry_run: None,
1515                },
1516            )
1517            .await;
1518            let summary = result.expect("ENOTEMPTY should not surface as an error");
1519            assert_eq!(summary.files_skipped, 1, "new file should be skipped");
1520            assert_eq!(
1521                summary.directories_removed, 0,
1522                "old_dir cannot be removed while new.txt remains"
1523            );
1524            assert!(old_dir.exists(), "old_dir should still exist");
1525            assert!(new_file.exists(), "new.txt should still exist");
1526            // the 'left intact' message is logged at info level
1527            assert!(
1528                logs_contain("not empty after filtering, leaving it intact"),
1529                "should log ENOTEMPTY case at info"
1530            );
1531            Ok(())
1532        }
1533
1534        /// An old, already-empty directory is removed by the time filter run.
1535        #[tokio::test]
1536        #[traced_test]
1537        async fn old_empty_directory_is_removed() -> Result<(), anyhow::Error> {
1538            let test_path = testutils::create_temp_dir().await?;
1539            let old_empty = test_path.join("old_empty");
1540            tokio::fs::create_dir(&old_empty).await?;
1541            set_mtime_age(&old_empty, std::time::Duration::from_secs(7200))?;
1542            set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1543            let summary = rm(
1544                &PROGRESS,
1545                &test_path,
1546                &Settings {
1547                    fail_early: false,
1548                    filter: None,
1549                    time_filter: Some(TimeFilter {
1550                        modified_before: Some(std::time::Duration::from_secs(3600)),
1551                        created_before: None,
1552                    }),
1553                    dry_run: None,
1554                },
1555            )
1556            .await?;
1557            // both old_empty and test_path itself are removed
1558            assert_eq!(summary.directories_removed, 2);
1559            assert!(!old_empty.exists());
1560            assert!(!test_path.exists());
1561            Ok(())
1562        }
1563
1564        /// Time filter combines with glob exclude — both must pass for removal.
1565        #[tokio::test]
1566        #[traced_test]
1567        async fn time_filter_combines_with_glob_exclude() -> Result<(), anyhow::Error> {
1568            let test_path = testutils::create_temp_dir().await?;
1569            let old_keep = test_path.join("keep.log");
1570            let old_drop = test_path.join("drop.txt");
1571            let new_drop = test_path.join("recent.txt");
1572            tokio::fs::write(&old_keep, "x").await?;
1573            tokio::fs::write(&old_drop, "x").await?;
1574            tokio::fs::write(&new_drop, "x").await?;
1575            set_mtime_age(&old_keep, std::time::Duration::from_secs(7200))?;
1576            set_mtime_age(&old_drop, std::time::Duration::from_secs(7200))?;
1577            set_mtime_age(&new_drop, std::time::Duration::from_secs(60))?;
1578            set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1579            let mut filter = crate::filter::FilterSettings::new();
1580            filter.add_exclude("*.log").unwrap();
1581            let summary = rm(
1582                &PROGRESS,
1583                &test_path,
1584                &Settings {
1585                    fail_early: false,
1586                    filter: Some(filter),
1587                    time_filter: Some(TimeFilter {
1588                        modified_before: Some(std::time::Duration::from_secs(3600)),
1589                        created_before: None,
1590                    }),
1591                    dry_run: None,
1592                },
1593            )
1594            .await?;
1595            // only old_drop passes both filters
1596            assert_eq!(summary.files_removed, 1, "only old_drop should be removed");
1597            assert_eq!(
1598                summary.files_skipped, 2,
1599                "old_keep and recent_drop should be skipped"
1600            );
1601            assert!(
1602                old_keep.exists(),
1603                "keep.log excluded by glob, should remain"
1604            );
1605            assert!(!old_drop.exists(), "drop.txt should be removed");
1606            assert!(new_drop.exists(), "recent.txt should remain (too new)");
1607            Ok(())
1608        }
1609
1610        /// Dry-run with time filter previews removal without modifying files.
1611        #[tokio::test]
1612        #[traced_test]
1613        async fn time_filter_with_dry_run() -> Result<(), anyhow::Error> {
1614            let test_path = testutils::create_temp_dir().await?;
1615            let old_file = test_path.join("old.txt");
1616            let new_file = test_path.join("new.txt");
1617            tokio::fs::write(&old_file, "x").await?;
1618            tokio::fs::write(&new_file, "x").await?;
1619            set_mtime_age(&old_file, std::time::Duration::from_secs(7200))?;
1620            set_mtime_age(&new_file, std::time::Duration::from_secs(60))?;
1621            set_mtime_age(&test_path, std::time::Duration::from_secs(7200))?;
1622            let summary = rm(
1623                &PROGRESS,
1624                &test_path,
1625                &Settings {
1626                    fail_early: false,
1627                    filter: None,
1628                    time_filter: Some(TimeFilter {
1629                        modified_before: Some(std::time::Duration::from_secs(3600)),
1630                        created_before: None,
1631                    }),
1632                    dry_run: Some(DryRunMode::Explain),
1633                },
1634            )
1635            .await?;
1636            assert_eq!(
1637                summary.files_removed, 1,
1638                "should report old file would be removed"
1639            );
1640            assert_eq!(
1641                summary.files_skipped, 1,
1642                "should report new file would be skipped"
1643            );
1644            assert!(old_file.exists(), "old.txt should still exist (dry-run)");
1645            assert!(new_file.exists(), "new.txt should still exist (dry-run)");
1646            Ok(())
1647        }
1648
1649        /// A fresh top-level directory is traversed (its old children are removed),
1650        /// but the root itself is not removed because its own mtime is too recent.
1651        #[tokio::test]
1652        #[traced_test]
1653        async fn fresh_top_level_directory_is_traversed_but_not_removed()
1654        -> Result<(), anyhow::Error> {
1655            let test_path = testutils::create_temp_dir().await?;
1656            let old_inside = test_path.join("old.txt");
1657            tokio::fs::write(&old_inside, "x").await?;
1658            set_mtime_age(&old_inside, std::time::Duration::from_secs(7200))?;
1659            // test_path itself is left fresh (recent mtime)
1660            let summary = rm(
1661                &PROGRESS,
1662                &test_path,
1663                &Settings {
1664                    fail_early: false,
1665                    filter: None,
1666                    time_filter: Some(TimeFilter {
1667                        modified_before: Some(std::time::Duration::from_secs(3600)),
1668                        created_before: None,
1669                    }),
1670                    dry_run: None,
1671                },
1672            )
1673            .await?;
1674            assert_eq!(
1675                summary.files_removed, 1,
1676                "old child should be removed despite fresh parent"
1677            );
1678            assert_eq!(
1679                summary.directories_skipped, 1,
1680                "fresh root itself is skipped at removal time"
1681            );
1682            assert_eq!(
1683                summary.directories_removed, 0,
1684                "fresh root must not be removed"
1685            );
1686            assert!(test_path.exists(), "fresh root should still exist");
1687            assert!(!old_inside.exists(), "old child should be gone");
1688            Ok(())
1689        }
1690
1691        /// Time filter on a single-file root argument increments skip when too new.
1692        #[tokio::test]
1693        #[traced_test]
1694        async fn time_filter_on_root_file_argument() -> Result<(), anyhow::Error> {
1695            let test_path = testutils::create_temp_dir().await?;
1696            let new_file = test_path.join("new.txt");
1697            tokio::fs::write(&new_file, "x").await?;
1698            set_mtime_age(&new_file, std::time::Duration::from_secs(60))?;
1699            let summary = rm(
1700                &PROGRESS,
1701                &new_file,
1702                &Settings {
1703                    fail_early: false,
1704                    filter: None,
1705                    time_filter: Some(TimeFilter {
1706                        modified_before: Some(std::time::Duration::from_secs(3600)),
1707                        created_before: None,
1708                    }),
1709                    dry_run: None,
1710                },
1711            )
1712            .await?;
1713            assert_eq!(summary.files_removed, 0);
1714            assert_eq!(
1715                summary.files_skipped, 1,
1716                "root file too new should be skipped"
1717            );
1718            assert!(new_file.exists(), "root file should still exist");
1719            Ok(())
1720        }
1721    }
1722
1723    /// Stress tests exercising max-open-files saturation during rm.
1724    mod max_open_files_tests {
1725        use super::*;
1726
1727        /// wide rm: many files with a very low open-files limit.
1728        /// verifies all files are removed correctly under permit saturation.
1729        #[tokio::test]
1730        #[traced_test]
1731        async fn wide_rm_under_open_files_saturation() -> Result<(), anyhow::Error> {
1732            let test_path = testutils::create_temp_dir().await?;
1733            let file_count = 200;
1734            for i in 0..file_count {
1735                tokio::fs::write(
1736                    test_path.join(format!("{}.txt", i)),
1737                    format!("content-{}", i),
1738                )
1739                .await?;
1740            }
1741            // set a very low limit to force permit contention
1742            throttle::set_max_open_files(4);
1743            let summary = rm(
1744                &PROGRESS,
1745                &test_path,
1746                &Settings {
1747                    fail_early: true,
1748                    filter: None,
1749                    dry_run: None,
1750                    time_filter: None,
1751                },
1752            )
1753            .await?;
1754            assert_eq!(summary.files_removed, file_count);
1755            assert_eq!(summary.directories_removed, 1);
1756            assert!(!test_path.exists());
1757            Ok(())
1758        }
1759
1760        /// deep + wide rm: directory tree deeper than the open-files limit, with files
1761        /// at every level. verifies no deadlock occurs (directories don't consume permits).
1762        #[tokio::test]
1763        #[traced_test]
1764        async fn deep_tree_no_deadlock_under_open_files_saturation() -> Result<(), anyhow::Error> {
1765            let test_path = testutils::create_temp_dir().await?;
1766            let depth = 20;
1767            let files_per_level = 5;
1768            let limit = 4;
1769            // create a directory chain deeper than the permit limit, with files at each level
1770            let mut dir = test_path.clone();
1771            for level in 0..depth {
1772                tokio::fs::create_dir_all(&dir).await?;
1773                for f in 0..files_per_level {
1774                    tokio::fs::write(
1775                        dir.join(format!("f{}_{}.txt", level, f)),
1776                        format!("L{}F{}", level, f),
1777                    )
1778                    .await?;
1779                }
1780                dir = dir.join(format!("d{}", level));
1781            }
1782            throttle::set_max_open_files(limit);
1783            let summary = tokio::time::timeout(
1784                std::time::Duration::from_secs(30),
1785                rm(
1786                    &PROGRESS,
1787                    &test_path,
1788                    &Settings {
1789                        fail_early: true,
1790                        filter: None,
1791                        dry_run: None,
1792                        time_filter: None,
1793                    },
1794                ),
1795            )
1796            .await
1797            .context("rm timed out — possible deadlock")?
1798            .context("rm failed")?;
1799            assert_eq!(summary.files_removed, depth * files_per_level);
1800            assert_eq!(summary.directories_removed, depth);
1801            assert!(!test_path.exists());
1802            Ok(())
1803        }
1804
1805        /// Locks down the boolean used at the rm spawn site to decide whether
1806        /// to pre-acquire a pending-meta permit. A naive `entry_is_dir = false
1807        /// ⇒ pre-acquire` policy treats unknown-typed entries (when
1808        /// `DirEntry::file_type()` fails) as leaves, so the spawned task
1809        /// holds the permit even if the entry is actually a directory and
1810        /// recurses. A chain of such entries can deadlock the pool. The
1811        /// safer pattern — `pre-acquire iff positively-known-not-directory`
1812        /// — keeps the predicate `false` for unknown types.
1813        #[test]
1814        fn pre_acquire_skips_unknown_filetype() -> Result<(), anyhow::Error> {
1815            let tmp = std::env::temp_dir().join(format!(
1816                "rcp_pre_acquire_test_{}_{}",
1817                std::process::id(),
1818                rand::random::<u64>()
1819            ));
1820            std::fs::create_dir(&tmp)?;
1821            let dir_path = tmp.join("d");
1822            std::fs::create_dir(&dir_path)?;
1823            let file_path = tmp.join("f");
1824            std::fs::write(&file_path, "x")?;
1825            let dir_ft = std::fs::metadata(&dir_path)?.file_type();
1826            let file_ft = std::fs::metadata(&file_path)?.file_type();
1827            // The exact predicate used in the rm spawn site:
1828            let known_leaf =
1829                |ft: Option<std::fs::FileType>| ft.as_ref().is_some_and(|t| !t.is_dir());
1830            assert!(!known_leaf(None), "unknown filetype must skip pre-acquire");
1831            assert!(!known_leaf(Some(dir_ft)), "directory must skip pre-acquire");
1832            assert!(known_leaf(Some(file_ft)), "regular file must pre-acquire");
1833            std::fs::remove_dir_all(&tmp).ok();
1834            Ok(())
1835        }
1836    }
1837}