1use anyhow::{anyhow, Context};
2use async_recursion::async_recursion;
3use std::os::unix::fs::PermissionsExt;
4use tracing::instrument;
5
6use crate::config::DryRunMode;
7use crate::filter::{FilterResult, FilterSettings};
8use crate::progress;
9
10#[derive(Debug, thiserror::Error)]
21#[error("{source:#}")]
22pub struct Error {
23 #[source]
24 pub source: anyhow::Error,
25 pub summary: Summary,
26}
27
28impl Error {
29 fn new(source: anyhow::Error, summary: Summary) -> Self {
30 Error { source, summary }
31 }
32}
33
34#[derive(Debug, Clone)]
35pub struct Settings {
36 pub fail_early: bool,
37 pub filter: Option<crate::filter::FilterSettings>,
39 pub dry_run: Option<crate::config::DryRunMode>,
41}
42
43fn report_dry_run_rm(path: &std::path::Path, entry_type: &str) {
45 println!("would remove {} {:?}", entry_type, path);
46}
47
48fn report_dry_run_skip(
50 path: &std::path::Path,
51 result: &FilterResult,
52 mode: DryRunMode,
53 entry_type: &str,
54) {
55 match mode {
56 DryRunMode::Brief => { }
57 DryRunMode::All => {
58 println!("skip {} {:?}", entry_type, path);
59 }
60 DryRunMode::Explain => match result {
61 FilterResult::ExcludedByDefault => {
62 println!(
63 "skip {} {:?} (no include pattern matched)",
64 entry_type, path
65 );
66 }
67 FilterResult::ExcludedByPattern(pattern) => {
68 println!("skip {} {:?} (excluded by '{}')", entry_type, path, pattern);
69 }
70 FilterResult::Included => { }
71 },
72 }
73}
74
75fn should_skip_entry(
77 filter: &Option<FilterSettings>,
78 relative_path: &std::path::Path,
79 is_dir: bool,
80) -> Option<FilterResult> {
81 if let Some(ref f) = filter {
82 let result = f.should_include(relative_path, is_dir);
83 match result {
84 FilterResult::Included => None,
85 _ => Some(result),
86 }
87 } else {
88 None
89 }
90}
91
92#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
93pub struct Summary {
94 pub bytes_removed: u64,
95 pub files_removed: usize,
96 pub symlinks_removed: usize,
97 pub directories_removed: usize,
98 pub files_skipped: usize,
99 pub symlinks_skipped: usize,
100 pub directories_skipped: usize,
101}
102
103impl std::ops::Add for Summary {
104 type Output = Self;
105 fn add(self, other: Self) -> Self {
106 Self {
107 bytes_removed: self.bytes_removed + other.bytes_removed,
108 files_removed: self.files_removed + other.files_removed,
109 symlinks_removed: self.symlinks_removed + other.symlinks_removed,
110 directories_removed: self.directories_removed + other.directories_removed,
111 files_skipped: self.files_skipped + other.files_skipped,
112 symlinks_skipped: self.symlinks_skipped + other.symlinks_skipped,
113 directories_skipped: self.directories_skipped + other.directories_skipped,
114 }
115 }
116}
117
118impl std::fmt::Display for Summary {
119 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
120 write!(
121 f,
122 "bytes removed: {}\n\
123 files removed: {}\n\
124 symlinks removed: {}\n\
125 directories removed: {}\n\
126 files skipped: {}\n\
127 symlinks skipped: {}\n\
128 directories skipped: {}\n",
129 bytesize::ByteSize(self.bytes_removed),
130 self.files_removed,
131 self.symlinks_removed,
132 self.directories_removed,
133 self.files_skipped,
134 self.symlinks_skipped,
135 self.directories_skipped
136 )
137 }
138}
139
140#[instrument(skip(prog_track, settings))]
143pub async fn rm(
144 prog_track: &'static progress::Progress,
145 path: &std::path::Path,
146 settings: &Settings,
147) -> Result<Summary, Error> {
148 if let Some(ref filter) = settings.filter {
150 let path_name = path.file_name().map(std::path::Path::new);
151 if let Some(name) = path_name {
152 let path_metadata = tokio::fs::symlink_metadata(path)
153 .await
154 .with_context(|| format!("failed reading metadata from {:?}", &path))
155 .map_err(|err| Error::new(err, Default::default()))?;
156 let is_dir = path_metadata.is_dir();
157 let result = filter.should_include_root_item(name, is_dir);
158 match result {
159 crate::filter::FilterResult::Included => {}
160 result => {
161 if let Some(mode) = settings.dry_run {
162 let entry_type = if path_metadata.is_dir() {
163 "directory"
164 } else if path_metadata.file_type().is_symlink() {
165 "symlink"
166 } else {
167 "file"
168 };
169 report_dry_run_skip(path, &result, mode, entry_type);
170 }
171 let skipped_summary = if path_metadata.is_dir() {
173 prog_track.directories_skipped.inc();
174 Summary {
175 directories_skipped: 1,
176 ..Default::default()
177 }
178 } else if path_metadata.file_type().is_symlink() {
179 prog_track.symlinks_skipped.inc();
180 Summary {
181 symlinks_skipped: 1,
182 ..Default::default()
183 }
184 } else {
185 prog_track.files_skipped.inc();
186 Summary {
187 files_skipped: 1,
188 ..Default::default()
189 }
190 };
191 return Ok(skipped_summary);
192 }
193 }
194 }
195 }
196 rm_internal(prog_track, path, path, settings).await
197}
198#[instrument(skip(prog_track, settings))]
199#[async_recursion]
200async fn rm_internal(
201 prog_track: &'static progress::Progress,
202 path: &std::path::Path,
203 source_root: &std::path::Path,
204 settings: &Settings,
205) -> Result<Summary, Error> {
206 let _ops_guard = prog_track.ops.guard();
207 tracing::debug!("read path metadata");
208 let src_metadata = tokio::fs::symlink_metadata(path)
209 .await
210 .with_context(|| format!("failed reading metadata from {:?}", &path))
211 .map_err(|err| Error::new(err, Default::default()))?;
212 if !src_metadata.is_dir() {
213 tracing::debug!("not a directory, just remove");
214 let is_symlink = src_metadata.file_type().is_symlink();
215 let file_size = if is_symlink { 0 } else { src_metadata.len() };
216 if settings.dry_run.is_some() {
218 let entry_type = if is_symlink { "symlink" } else { "file" };
219 report_dry_run_rm(path, entry_type);
220 return Ok(Summary {
221 bytes_removed: file_size,
222 files_removed: if is_symlink { 0 } else { 1 },
223 symlinks_removed: if is_symlink { 1 } else { 0 },
224 ..Default::default()
225 });
226 }
227 tokio::fs::remove_file(path)
228 .await
229 .with_context(|| format!("failed removing {:?}", &path))
230 .map_err(|err| Error::new(err, Default::default()))?;
231 if is_symlink {
232 prog_track.symlinks_removed.inc();
233 return Ok(Summary {
234 symlinks_removed: 1,
235 ..Default::default()
236 });
237 }
238 prog_track.files_removed.inc();
239 prog_track.bytes_removed.add(file_size);
240 return Ok(Summary {
241 bytes_removed: file_size,
242 files_removed: 1,
243 ..Default::default()
244 });
245 }
246 tracing::debug!("remove contents of the directory first");
247 if settings.dry_run.is_none() && src_metadata.permissions().readonly() {
249 tracing::debug!("directory is read-only - change the permissions");
250 tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o777))
251 .await
252 .with_context(|| {
253 format!(
254 "failed to make '{:?}' directory readable and writeable",
255 &path
256 )
257 })
258 .map_err(|err| Error::new(err, Default::default()))?;
259 }
260 let mut entries = tokio::fs::read_dir(path)
261 .await
262 .with_context(|| format!("failed reading directory {:?}", &path))
263 .map_err(|err| Error::new(err, Default::default()))?;
264 let mut join_set = tokio::task::JoinSet::new();
265 let errors = crate::error_collector::ErrorCollector::default();
266 let mut skipped_files = 0;
267 let mut skipped_symlinks = 0;
268 let mut skipped_dirs = 0;
269 while let Some(entry) = entries
270 .next_entry()
271 .await
272 .with_context(|| format!("failed traversing directory {:?}", &path))
273 .map_err(|err| Error::new(err, Default::default()))?
274 {
275 throttle::get_ops_token().await;
279 let entry_path = entry.path();
280 let entry_file_type = entry.file_type().await.ok();
282 let entry_is_dir = entry_file_type.map(|ft| ft.is_dir()).unwrap_or(false);
283 let entry_is_symlink = entry_file_type.map(|ft| ft.is_symlink()).unwrap_or(false);
284 let relative_path = entry_path.strip_prefix(source_root).unwrap_or(&entry_path);
286 if let Some(skip_result) = should_skip_entry(&settings.filter, relative_path, entry_is_dir)
288 {
289 if let Some(mode) = settings.dry_run {
290 let entry_type = if entry_is_dir {
291 "dir"
292 } else if entry_is_symlink {
293 "symlink"
294 } else {
295 "file"
296 };
297 report_dry_run_skip(&entry_path, &skip_result, mode, entry_type);
298 }
299 tracing::debug!("skipping {:?} due to filter", &entry_path);
300 if entry_is_dir {
302 skipped_dirs += 1;
303 prog_track.directories_skipped.inc();
304 } else if entry_is_symlink {
305 skipped_symlinks += 1;
306 prog_track.symlinks_skipped.inc();
307 } else {
308 skipped_files += 1;
309 prog_track.files_skipped.inc();
310 }
311 continue;
312 }
313 let settings = settings.clone();
314 let source_root = source_root.to_owned();
315 let do_rm =
316 || async move { rm_internal(prog_track, &entry_path, &source_root, &settings).await };
317 join_set.spawn(do_rm());
318 }
319 drop(entries);
322 let mut rm_summary = Summary {
323 directories_removed: 0,
324 files_skipped: skipped_files,
325 symlinks_skipped: skipped_symlinks,
326 directories_skipped: skipped_dirs,
327 ..Default::default()
328 };
329 while let Some(res) = join_set.join_next().await {
330 match res {
331 Ok(result) => match result {
332 Ok(summary) => rm_summary = rm_summary + summary,
333 Err(error) => {
334 tracing::error!("remove: {:?} failed with: {:#}", path, &error);
335 rm_summary = rm_summary + error.summary;
336 errors.push(error.source);
337 if settings.fail_early {
338 break;
339 }
340 }
341 },
342 Err(error) => {
343 errors.push(error.into());
344 if settings.fail_early {
345 break;
346 }
347 }
348 }
349 }
350 if errors.has_errors() {
351 return Err(Error::new(errors.into_error().unwrap(), rm_summary));
353 }
354 tracing::debug!("finally remove the empty directory");
355 let anything_removed = rm_summary.files_removed > 0
356 || rm_summary.symlinks_removed > 0
357 || rm_summary.directories_removed > 0;
358 let anything_skipped = rm_summary.files_skipped > 0
359 || rm_summary.symlinks_skipped > 0
360 || rm_summary.directories_skipped > 0;
361 let relative_path = path.strip_prefix(source_root).unwrap_or(path);
368 let traversed_only = !anything_removed
369 && settings
370 .filter
371 .as_ref()
372 .is_some_and(|f| f.has_includes() && !f.directly_matches_include(relative_path, true));
373 if settings.dry_run.is_some() {
380 if traversed_only || anything_skipped {
381 tracing::debug!(
382 "dry-run: directory {:?} would not be removed (removed={}, skipped={})",
383 &path,
384 anything_removed,
385 anything_skipped
386 );
387 } else {
388 report_dry_run_rm(path, "dir");
389 rm_summary.directories_removed += 1;
390 }
391 return Ok(rm_summary);
392 }
393 if traversed_only {
397 tracing::debug!(
398 "directory {:?} had nothing removed, leaving it intact",
399 &path
400 );
401 return Ok(rm_summary);
402 }
403 match tokio::fs::remove_dir(path).await {
407 Ok(()) => {
408 prog_track.directories_removed.inc();
409 rm_summary.directories_removed += 1;
410 }
411 Err(err) if settings.filter.is_some() => {
412 if err.kind() == std::io::ErrorKind::DirectoryNotEmpty || err.raw_os_error() == Some(39)
415 {
416 tracing::debug!(
417 "directory {:?} not empty after filtering, leaving it intact",
418 &path
419 );
420 } else {
421 return Err(Error::new(
422 anyhow!(err).context(format!("failed removing directory {:?}", &path)),
423 rm_summary,
424 ));
425 }
426 }
427 Err(err) => {
428 return Err(Error::new(
429 anyhow!(err).context(format!("failed removing directory {:?}", &path)),
430 rm_summary,
431 ));
432 }
433 }
434 Ok(rm_summary)
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use crate::testutils;
441 use tracing_test::traced_test;
442
443 static PROGRESS: std::sync::LazyLock<progress::Progress> =
444 std::sync::LazyLock::new(progress::Progress::new);
445
446 #[tokio::test]
447 #[traced_test]
448 async fn no_write_permission() -> Result<(), anyhow::Error> {
449 let tmp_dir = testutils::setup_test_dir().await?;
450 let test_path = tmp_dir.as_path();
451 let filepaths = vec![
452 test_path.join("foo").join("0.txt"),
453 test_path.join("foo").join("bar").join("2.txt"),
454 test_path.join("foo").join("baz").join("4.txt"),
455 test_path.join("foo").join("baz"),
456 ];
457 for fpath in &filepaths {
458 tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o555)).await?;
460 }
461 let summary = rm(
462 &PROGRESS,
463 &test_path.join("foo"),
464 &Settings {
465 fail_early: false,
466 filter: None,
467 dry_run: None,
468 },
469 )
470 .await?;
471 assert!(!test_path.join("foo").exists());
472 assert_eq!(summary.files_removed, 5);
473 assert_eq!(summary.symlinks_removed, 2);
474 assert_eq!(summary.directories_removed, 3);
475 Ok(())
476 }
477
478 #[tokio::test]
479 #[traced_test]
480 async fn parent_dir_no_write_permission() -> Result<(), anyhow::Error> {
481 let tmp_dir = testutils::setup_test_dir().await?;
482 let test_path = tmp_dir.as_path();
483 tokio::fs::set_permissions(
485 &test_path.join("foo").join("bar"),
486 std::fs::Permissions::from_mode(0o555),
487 )
488 .await?;
489 let result = rm(
490 &PROGRESS,
491 &test_path.join("foo").join("bar").join("2.txt"),
492 &Settings {
493 fail_early: true,
494 filter: None,
495 dry_run: None,
496 },
497 )
498 .await;
499 assert!(result.is_err());
501 let err = result.unwrap_err();
502 let err_string = format!("{:#}", err);
503 assert!(
505 err_string.contains("Permission denied") || err_string.contains("permission denied"),
506 "Error should contain 'Permission denied' but got: {}",
507 err_string
508 );
509 Ok(())
510 }
511 mod filter_tests {
512 use super::*;
513 use crate::filter::FilterSettings;
514 #[tokio::test]
516 #[traced_test]
517 async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
518 let tmp_dir = testutils::setup_test_dir().await?;
519 let test_path = tmp_dir.as_path();
520 let mut filter = FilterSettings::new();
522 filter.add_include("bar/*.txt").unwrap();
523 let summary = rm(
524 &PROGRESS,
525 &test_path.join("foo"),
526 &Settings {
527 fail_early: false,
528 filter: Some(filter),
529 dry_run: None,
530 },
531 )
532 .await?;
533 assert_eq!(
535 summary.files_removed, 3,
536 "should remove 3 files matching bar/*.txt"
537 );
538 assert_eq!(summary.bytes_removed, 3, "should report 3 bytes removed");
540 assert!(
542 !test_path.join("foo/bar/1.txt").exists(),
543 "bar/1.txt should be removed"
544 );
545 assert!(
546 !test_path.join("foo/bar/2.txt").exists(),
547 "bar/2.txt should be removed"
548 );
549 assert!(
550 !test_path.join("foo/bar/3.txt").exists(),
551 "bar/3.txt should be removed"
552 );
553 assert!(
555 test_path.join("foo/0.txt").exists(),
556 "0.txt should still exist"
557 );
558 Ok(())
559 }
560 #[tokio::test]
562 #[traced_test]
563 async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
564 let tmp_dir = testutils::setup_test_dir().await?;
565 let test_path = tmp_dir.as_path();
566 let mut filter = FilterSettings::new();
568 filter.add_exclude("*.txt").unwrap();
569 let summary = rm(
570 &PROGRESS,
571 &test_path.join("foo/0.txt"), &Settings {
573 fail_early: false,
574 filter: Some(filter),
575 dry_run: None,
576 },
577 )
578 .await?;
579 assert_eq!(
581 summary.files_removed, 0,
582 "file matching exclude pattern should not be removed"
583 );
584 assert!(
585 test_path.join("foo/0.txt").exists(),
586 "excluded file should still exist"
587 );
588 Ok(())
589 }
590 #[tokio::test]
592 #[traced_test]
593 async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
594 let test_path = testutils::create_temp_dir().await?;
595 tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
597 tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
598 let mut filter = FilterSettings::new();
600 filter.add_exclude("*_dir/").unwrap();
601 let result = rm(
602 &PROGRESS,
603 &test_path.join("excluded_dir"),
604 &Settings {
605 fail_early: false,
606 filter: Some(filter),
607 dry_run: None,
608 },
609 )
610 .await?;
611 assert_eq!(
613 result.directories_removed, 0,
614 "root directory matching exclude should not be removed"
615 );
616 assert!(
617 test_path.join("excluded_dir").exists(),
618 "excluded root directory should still exist"
619 );
620 Ok(())
621 }
622 #[tokio::test]
624 #[traced_test]
625 async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
626 let test_path = testutils::create_temp_dir().await?;
627 tokio::fs::write(test_path.join("target.txt"), "content").await?;
629 tokio::fs::symlink(
630 test_path.join("target.txt"),
631 test_path.join("excluded_link"),
632 )
633 .await?;
634 let mut filter = FilterSettings::new();
636 filter.add_exclude("*_link").unwrap();
637 let result = rm(
638 &PROGRESS,
639 &test_path.join("excluded_link"),
640 &Settings {
641 fail_early: false,
642 filter: Some(filter),
643 dry_run: None,
644 },
645 )
646 .await?;
647 assert_eq!(
649 result.symlinks_removed, 0,
650 "root symlink matching exclude should not be removed"
651 );
652 assert!(
653 test_path.join("excluded_link").exists(),
654 "excluded root symlink should still exist"
655 );
656 Ok(())
657 }
658 #[tokio::test]
660 #[traced_test]
661 async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
662 let tmp_dir = testutils::setup_test_dir().await?;
663 let test_path = tmp_dir.as_path();
664 let mut filter = FilterSettings::new();
671 filter.add_include("bar/*.txt").unwrap();
672 filter.add_exclude("bar/2.txt").unwrap();
673 let summary = rm(
674 &PROGRESS,
675 &test_path.join("foo"),
676 &Settings {
677 fail_early: false,
678 filter: Some(filter),
679 dry_run: None,
680 },
681 )
682 .await?;
683 assert_eq!(summary.files_removed, 2, "should remove 2 files");
686 assert_eq!(
687 summary.files_skipped, 2,
688 "should skip 2 files (bar/2.txt excluded, 0.txt no match)"
689 );
690 assert!(
692 !test_path.join("foo/bar/1.txt").exists(),
693 "bar/1.txt should be removed"
694 );
695 assert!(
696 test_path.join("foo/bar/2.txt").exists(),
697 "bar/2.txt should be excluded"
698 );
699 assert!(
700 !test_path.join("foo/bar/3.txt").exists(),
701 "bar/3.txt should be removed"
702 );
703 Ok(())
704 }
705 #[tokio::test]
707 #[traced_test]
708 async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
709 let tmp_dir = testutils::setup_test_dir().await?;
710 let test_path = tmp_dir.as_path();
711 let mut filter = FilterSettings::new();
718 filter.add_exclude("bar/").unwrap();
719 let summary = rm(
720 &PROGRESS,
721 &test_path.join("foo"),
722 &Settings {
723 fail_early: false,
724 filter: Some(filter),
725 dry_run: None,
726 },
727 )
728 .await?;
729 assert_eq!(summary.files_removed, 2, "should remove 2 files");
734 assert_eq!(summary.symlinks_removed, 2, "should remove 2 symlinks");
735 assert_eq!(
736 summary.directories_removed, 1,
737 "should remove 1 directory (baz only, foo not empty)"
738 );
739 assert_eq!(
740 summary.directories_skipped, 1,
741 "should skip 1 directory (bar)"
742 );
743 assert!(
745 test_path.join("foo/bar").exists(),
746 "bar directory should still exist"
747 );
748 assert!(
750 test_path.join("foo").exists(),
751 "foo directory should still exist (contains bar)"
752 );
753 Ok(())
754 }
755 #[tokio::test]
758 #[traced_test]
759 async fn test_empty_dir_not_removed_when_only_traversed() -> Result<(), anyhow::Error> {
760 let test_path = testutils::create_temp_dir().await?;
761 tokio::fs::write(test_path.join("foo"), "content").await?;
767 tokio::fs::write(test_path.join("bar"), "content").await?;
768 tokio::fs::create_dir(test_path.join("baz")).await?;
769 let mut filter = FilterSettings::new();
771 filter.add_include("foo").unwrap();
772 let summary = rm(
773 &PROGRESS,
774 &test_path,
775 &Settings {
776 fail_early: false,
777 filter: Some(filter),
778 dry_run: None,
779 },
780 )
781 .await?;
782 assert_eq!(summary.files_removed, 1, "should remove only 'foo' file");
784 assert_eq!(
785 summary.directories_removed, 0,
786 "should NOT remove empty 'baz' directory"
787 );
788 assert!(!test_path.join("foo").exists(), "foo should be removed");
790 assert!(test_path.join("bar").exists(), "bar should still exist");
792 assert!(
794 test_path.join("baz").exists(),
795 "empty baz directory should NOT be removed"
796 );
797 Ok(())
798 }
799 #[tokio::test]
803 #[traced_test]
804 async fn test_exclude_only_removes_empty_directory() -> Result<(), anyhow::Error> {
805 let test_path = testutils::create_temp_dir().await?;
806 tokio::fs::write(test_path.join("foo"), "content").await?;
812 tokio::fs::write(test_path.join("bar.log"), "content").await?;
813 tokio::fs::create_dir(test_path.join("baz")).await?;
814 let mut filter = FilterSettings::new();
816 filter.add_exclude("*.log").unwrap();
817 let summary = rm(
818 &PROGRESS,
819 &test_path,
820 &Settings {
821 fail_early: false,
822 filter: Some(filter),
823 dry_run: None,
824 },
825 )
826 .await?;
827 assert_eq!(summary.files_removed, 1, "should remove 'foo'");
829 assert_eq!(summary.files_skipped, 1, "should skip 'bar.log'");
830 assert_eq!(
831 summary.directories_removed, 1,
832 "should remove empty 'baz' directory"
833 );
834 assert!(!test_path.join("foo").exists(), "foo should be removed");
835 assert!(
836 test_path.join("bar.log").exists(),
837 "bar.log should still exist"
838 );
839 assert!(
840 !test_path.join("baz").exists(),
841 "empty baz directory should be removed"
842 );
843 Ok(())
844 }
845 #[tokio::test]
847 #[traced_test]
848 async fn test_dry_run_empty_dir_not_reported_as_removed() -> Result<(), anyhow::Error> {
849 let test_path = testutils::create_temp_dir().await?;
850 tokio::fs::write(test_path.join("foo"), "content").await?;
856 tokio::fs::write(test_path.join("bar"), "content").await?;
857 tokio::fs::create_dir(test_path.join("baz")).await?;
858 let mut filter = FilterSettings::new();
860 filter.add_include("foo").unwrap();
861 let summary = rm(
862 &PROGRESS,
863 &test_path,
864 &Settings {
865 fail_early: false,
866 filter: Some(filter),
867 dry_run: Some(DryRunMode::Explain),
868 },
869 )
870 .await?;
871 assert_eq!(
873 summary.files_removed, 1,
874 "should report only 'foo' would be removed"
875 );
876 assert_eq!(
877 summary.directories_removed, 0,
878 "should NOT report empty 'baz' would be removed"
879 );
880 assert!(test_path.join("foo").exists(), "foo should still exist");
882 assert!(test_path.join("bar").exists(), "bar should still exist");
883 assert!(test_path.join("baz").exists(), "baz should still exist");
884 Ok(())
885 }
886 #[tokio::test]
889 #[traced_test]
890 async fn test_include_directly_matched_empty_dir_is_removed() -> Result<(), anyhow::Error> {
891 let test_path = testutils::create_temp_dir().await?;
892 tokio::fs::write(test_path.join("foo"), "content").await?;
897 tokio::fs::create_dir(test_path.join("baz")).await?;
898 let mut filter = FilterSettings::new();
900 filter.add_include("baz/").unwrap();
901 let summary = rm(
902 &PROGRESS,
903 &test_path,
904 &Settings {
905 fail_early: false,
906 filter: Some(filter),
907 dry_run: None,
908 },
909 )
910 .await?;
911 assert_eq!(
912 summary.directories_removed, 1,
913 "should remove directly matched empty 'baz' directory"
914 );
915 assert_eq!(summary.files_removed, 0, "should not remove 'foo'");
916 assert!(test_path.join("foo").exists(), "foo should still exist");
917 assert!(
918 !test_path.join("baz").exists(),
919 "directly matched empty baz directory should be removed"
920 );
921 Ok(())
922 }
923 }
924 mod dry_run_tests {
925 use super::*;
926 #[tokio::test]
928 #[traced_test]
929 async fn test_dry_run_preserves_readonly_permissions() -> Result<(), anyhow::Error> {
930 let tmp_dir = testutils::setup_test_dir().await?;
931 let test_path = tmp_dir.as_path();
932 let readonly_dir = test_path.join("foo/bar");
933 tokio::fs::set_permissions(&readonly_dir, std::fs::Permissions::from_mode(0o555))
935 .await?;
936 let before_mode = tokio::fs::metadata(&readonly_dir)
938 .await?
939 .permissions()
940 .mode()
941 & 0o777;
942 assert_eq!(
943 before_mode, 0o555,
944 "directory should be read-only before dry-run"
945 );
946 let summary = rm(
947 &PROGRESS,
948 &readonly_dir,
949 &Settings {
950 fail_early: false,
951 filter: None,
952 dry_run: Some(DryRunMode::Brief),
953 },
954 )
955 .await?;
956 assert!(
958 readonly_dir.exists(),
959 "directory should still exist after dry-run"
960 );
961 let after_mode = tokio::fs::metadata(&readonly_dir)
963 .await?
964 .permissions()
965 .mode()
966 & 0o777;
967 assert_eq!(
968 after_mode, 0o555,
969 "dry-run should not modify directory permissions"
970 );
971 assert!(
973 summary.directories_removed > 0 || summary.files_removed > 0,
974 "dry-run should report what would be removed"
975 );
976 Ok(())
977 }
978 #[tokio::test]
981 #[traced_test]
982 async fn test_dry_run_with_filter_non_empty_directory() -> Result<(), anyhow::Error> {
983 let tmp_dir = testutils::setup_test_dir().await?;
984 let test_path = tmp_dir.as_path();
985 let mut filter = crate::filter::FilterSettings::new();
992 filter.add_exclude("bar/").unwrap();
993 let summary = rm(
994 &PROGRESS,
995 &test_path.join("foo"),
996 &Settings {
997 fail_early: false,
998 filter: Some(filter),
999 dry_run: Some(DryRunMode::Brief),
1000 },
1001 )
1002 .await?;
1003 assert!(
1005 test_path.join("foo").exists(),
1006 "foo should still exist after dry-run"
1007 );
1008 assert_eq!(
1014 summary.files_removed, 2,
1015 "should report 2 files would be removed"
1016 );
1017 assert_eq!(
1018 summary.symlinks_removed, 2,
1019 "should report 2 symlinks would be removed"
1020 );
1021 assert_eq!(
1022 summary.directories_removed, 1,
1023 "should report only baz (not foo) would be removed"
1024 );
1025 assert_eq!(
1026 summary.directories_skipped, 1,
1027 "should report bar directory skipped"
1028 );
1029 Ok(())
1030 }
1031 #[tokio::test]
1034 #[traced_test]
1035 async fn test_dry_run_exclude_only_reports_empty_dir_removed() -> Result<(), anyhow::Error>
1036 {
1037 let test_path = testutils::create_temp_dir().await?;
1038 tokio::fs::write(test_path.join("foo"), "content").await?;
1044 tokio::fs::write(test_path.join("bar.log"), "content").await?;
1045 tokio::fs::create_dir(test_path.join("baz")).await?;
1046 let mut filter = FilterSettings::new();
1048 filter.add_exclude("*.log").unwrap();
1049 let summary = rm(
1050 &PROGRESS,
1051 &test_path,
1052 &Settings {
1053 fail_early: false,
1054 filter: Some(filter),
1055 dry_run: Some(DryRunMode::Explain),
1056 },
1057 )
1058 .await?;
1059 assert_eq!(
1061 summary.files_removed, 1,
1062 "should report 'foo' would be removed"
1063 );
1064 assert_eq!(
1065 summary.files_skipped, 1,
1066 "should report 'bar.log' would be skipped"
1067 );
1068 assert_eq!(
1069 summary.directories_removed, 1,
1070 "should report empty 'baz' directory would be removed"
1071 );
1072 assert!(test_path.join("foo").exists(), "foo should still exist");
1074 assert!(
1075 test_path.join("bar.log").exists(),
1076 "bar.log should still exist"
1077 );
1078 assert!(test_path.join("baz").exists(), "baz should still exist");
1079 Ok(())
1080 }
1081 #[tokio::test]
1084 #[traced_test]
1085 async fn test_dry_run_include_directly_matched_empty_dir_reported(
1086 ) -> Result<(), anyhow::Error> {
1087 let test_path = testutils::create_temp_dir().await?;
1088 tokio::fs::write(test_path.join("foo"), "content").await?;
1093 tokio::fs::create_dir(test_path.join("baz")).await?;
1094 let mut filter = FilterSettings::new();
1096 filter.add_include("baz/").unwrap();
1097 let summary = rm(
1098 &PROGRESS,
1099 &test_path,
1100 &Settings {
1101 fail_early: false,
1102 filter: Some(filter),
1103 dry_run: Some(DryRunMode::Explain),
1104 },
1105 )
1106 .await?;
1107 assert_eq!(
1108 summary.directories_removed, 1,
1109 "should report directly matched empty 'baz' would be removed"
1110 );
1111 assert_eq!(summary.files_removed, 0, "should not report 'foo'");
1112 assert!(test_path.join("foo").exists(), "foo should still exist");
1114 assert!(test_path.join("baz").exists(), "baz should still exist");
1115 Ok(())
1116 }
1117 }
1118}