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
10pub type Error = crate::error::OperationError<Summary>;
13
14#[derive(Debug, Clone)]
15pub struct Settings {
16 pub fail_early: bool,
17 pub filter: Option<crate::filter::FilterSettings>,
19 pub time_filter: Option<TimeFilter>,
27 pub dry_run: Option<crate::config::DryRunMode>,
29}
30
31fn 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
43fn 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
111struct RelaxedDirGuard<'a> {
124 path: &'a std::path::Path,
125 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 fn arm(&mut self, original_mode: u32) {
135 self.mode = Some(original_mode);
136 }
137 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#[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 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 rm_internal(prog_track, path, path, settings).await
196}
197
198pub 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 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 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 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 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 let relative_path = walk::relative_to_root(&entry_path, source_root);
380 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 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 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 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 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 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 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 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 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 if traversed_only {
549 tracing::debug!(
550 "directory {:?} had nothing removed, leaving it intact",
551 &path
552 );
553 return Ok(rm_summary);
554 }
555 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 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 relaxed_guard.defuse();
582 }
583 Err(err) if any_filter_active => {
584 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 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 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 tokio::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o555)).await?;
674 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 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 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 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 assert!(result.is_err());
731 let err = result.unwrap_err();
732 let err_string = format!("{:#}", err);
733 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 #[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 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 assert_eq!(
767 summary.files_removed, 3,
768 "should remove 3 files matching bar/*.txt"
769 );
770 assert_eq!(summary.bytes_removed, 3, "should report 3 bytes removed");
772 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 assert!(
787 test_path.join("foo/0.txt").exists(),
788 "0.txt should still exist"
789 );
790 Ok(())
791 }
792 #[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 let mut filter = FilterSettings::new();
800 filter.add_exclude("*.txt").unwrap();
801 let summary = rm(
802 &PROGRESS,
803 &test_path.join("foo/0.txt"), &Settings {
805 fail_early: false,
806 filter: Some(filter),
807 dry_run: None,
808 time_filter: None,
809 },
810 )
811 .await?;
812 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 #[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 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 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 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 #[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 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 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 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 #[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 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 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 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 #[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 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 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 assert!(
982 test_path.join("foo/bar").exists(),
983 "bar directory should still exist"
984 );
985 assert!(
987 test_path.join("foo").exists(),
988 "foo directory should still exist (contains bar)"
989 );
990 Ok(())
991 }
992 #[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 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 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 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 assert!(!test_path.join("foo").exists(), "foo should be removed");
1028 assert!(test_path.join("bar").exists(), "bar should still exist");
1030 assert!(
1032 test_path.join("baz").exists(),
1033 "empty baz directory should NOT be removed"
1034 );
1035 Ok(())
1036 }
1037 #[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 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 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 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 #[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 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 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 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 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 #[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 tokio::fs::write(test_path.join("foo"), "content").await?;
1137 tokio::fs::create_dir(test_path.join("baz")).await?;
1138 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 #[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 tokio::fs::set_permissions(&readonly_dir, std::fs::Permissions::from_mode(0o555))
1177 .await?;
1178 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 assert!(
1201 readonly_dir.exists(),
1202 "directory should still exist after dry-run"
1203 );
1204 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 assert!(
1216 summary.directories_removed > 0 || summary.files_removed > 0,
1217 "dry-run should report what would be removed"
1218 );
1219 Ok(())
1220 }
1221 #[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 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 assert!(
1249 test_path.join("foo").exists(),
1250 "foo should still exist after dry-run"
1251 );
1252 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 #[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 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 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 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 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 #[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 tokio::fs::write(test_path.join("foo"), "content").await?;
1338 tokio::fs::create_dir(test_path.join("baz")).await?;
1339 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 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 #[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 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 #[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 #[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 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 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 #[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 assert!(
1528 logs_contain("not empty after filtering, leaving it intact"),
1529 "should log ENOTEMPTY case at info"
1530 );
1531 Ok(())
1532 }
1533
1534 #[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 assert_eq!(summary.directories_removed, 2);
1559 assert!(!old_empty.exists());
1560 assert!(!test_path.exists());
1561 Ok(())
1562 }
1563
1564 #[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 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 #[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 #[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 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 #[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 mod max_open_files_tests {
1725 use super::*;
1726
1727 #[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 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 #[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 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 #[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 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}