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
111#[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 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 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 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 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 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 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 let relative_path = entry_path.strip_prefix(source_root).unwrap_or(&entry_path);
299 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 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 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 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 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 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 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 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 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 if traversed_only {
468 tracing::debug!(
469 "directory {:?} had nothing removed, leaving it intact",
470 &path
471 );
472 return Ok(rm_summary);
473 }
474 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 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 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 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 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 assert!(result.is_err());
594 let err = result.unwrap_err();
595 let err_string = format!("{:#}", err);
596 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 #[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 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 assert_eq!(
629 summary.files_removed, 3,
630 "should remove 3 files matching bar/*.txt"
631 );
632 assert_eq!(summary.bytes_removed, 3, "should report 3 bytes removed");
634 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 assert!(
649 test_path.join("foo/0.txt").exists(),
650 "0.txt should still exist"
651 );
652 Ok(())
653 }
654 #[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 let mut filter = FilterSettings::new();
662 filter.add_exclude("*.txt").unwrap();
663 let summary = rm(
664 &PROGRESS,
665 &test_path.join("foo/0.txt"), &Settings {
667 fail_early: false,
668 filter: Some(filter),
669 dry_run: None,
670 time_filter: None,
671 },
672 )
673 .await?;
674 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 #[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 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 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 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 #[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 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 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 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 #[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 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 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 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 #[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 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 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 assert!(
844 test_path.join("foo/bar").exists(),
845 "bar directory should still exist"
846 );
847 assert!(
849 test_path.join("foo").exists(),
850 "foo directory should still exist (contains bar)"
851 );
852 Ok(())
853 }
854 #[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 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 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 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 assert!(!test_path.join("foo").exists(), "foo should be removed");
890 assert!(test_path.join("bar").exists(), "bar should still exist");
892 assert!(
894 test_path.join("baz").exists(),
895 "empty baz directory should NOT be removed"
896 );
897 Ok(())
898 }
899 #[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 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 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 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 #[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 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 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 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 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 #[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 tokio::fs::write(test_path.join("foo"), "content").await?;
999 tokio::fs::create_dir(test_path.join("baz")).await?;
1000 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 #[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 tokio::fs::set_permissions(&readonly_dir, std::fs::Permissions::from_mode(0o555))
1039 .await?;
1040 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 assert!(
1063 readonly_dir.exists(),
1064 "directory should still exist after dry-run"
1065 );
1066 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 assert!(
1078 summary.directories_removed > 0 || summary.files_removed > 0,
1079 "dry-run should report what would be removed"
1080 );
1081 Ok(())
1082 }
1083 #[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 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 assert!(
1111 test_path.join("foo").exists(),
1112 "foo should still exist after dry-run"
1113 );
1114 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 #[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 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 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 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 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 #[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 tokio::fs::write(test_path.join("foo"), "content").await?;
1200 tokio::fs::create_dir(test_path.join("baz")).await?;
1201 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 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 #[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 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 #[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 #[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 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 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 #[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 assert!(
1390 logs_contain("not empty after filtering, leaving it intact"),
1391 "should log ENOTEMPTY case at info"
1392 );
1393 Ok(())
1394 }
1395
1396 #[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 assert_eq!(summary.directories_removed, 2);
1421 assert!(!old_empty.exists());
1422 assert!(!test_path.exists());
1423 Ok(())
1424 }
1425
1426 #[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 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 #[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 #[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 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 #[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 mod max_open_files_tests {
1587 use super::*;
1588
1589 #[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 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 #[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 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 #[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 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}