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