Skip to main content

common/
rm.rs

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/// Error type for remove operations that preserves operation summary even on failure.
11///
12/// # Logging Convention
13/// When logging this error, use `{:#}` or `{:?}` format to preserve the error chain:
14/// ```ignore
15/// tracing::error!("operation failed: {:#}", &error); // ✅ Shows full chain
16/// tracing::error!("operation failed: {:?}", &error); // ✅ Shows full chain
17/// ```
18/// The Display implementation also shows the full chain, but workspace linting enforces `{:#}`
19/// for consistency.
20#[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    /// filter settings for include/exclude patterns
38    pub filter: Option<crate::filter::FilterSettings>,
39    /// dry-run mode for previewing operations
40    pub dry_run: Option<crate::config::DryRunMode>,
41}
42
43/// Reports a dry-run action for remove operations
44fn report_dry_run_rm(path: &std::path::Path, entry_type: &str) {
45    println!("would remove {} {:?}", entry_type, path);
46}
47
48/// Reports a skipped entry during dry-run
49fn 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 => { /* brief mode doesn't show skipped files */ }
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 => { /* shouldn't happen */ }
71        },
72    }
73}
74
75/// Check if a path should be filtered out
76fn 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/// Public entry point for remove operations.
141/// Internally delegates to rm_internal with source_root tracking for proper filter matching.
142#[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    // check filter for top-level path (files, directories, and symlinks)
149    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                    // return summary with skipped count
172                    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        // handle dry-run mode for files/symlinks
217        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    // only change permissions if not in dry-run mode
248    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        // it's better to await the token here so that we throttle the syscalls generated by the
276        // DirEntry call. the ops-throttle will never cause a deadlock (unlike max-open-files limit)
277        // so it's safe to do here.
278        throttle::get_ops_token().await;
279        let entry_path = entry.path();
280        // check entry type for filter matching and skip counting
281        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        // compute relative path from source_root for filter matching
285        let relative_path = entry_path.strip_prefix(source_root).unwrap_or(&entry_path);
286        // apply filter if configured
287        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            // increment skipped counters - will be added to rm_summary below
301            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    // unfortunately ReadDir is opening file-descriptors and there's not a good way to limit this,
320    // one thing we CAN do however is to drop it as soon as we're done with it
321    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        // unwrap is safe: has_errors() guarantees into_error() returns Some
352        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    // a directory is "traversed only" when include filters are active, nothing was removed
362    // from it, and the directory itself doesn't directly match an include pattern. such
363    // directories were only entered to search for matching content inside and should be
364    // left intact. directories that directly match an include pattern (e.g. --include target/)
365    // should be removed even if empty. exclude-only filters never produce traversed-only
366    // directories because directly_matches_include returns true when no includes exist.
367    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    // handle dry-run mode for directories.
374    // `traversed_only` catches dirs only entered to search for include pattern matches.
375    // `anything_skipped` catches dirs that would still have content after partial removal
376    // (applies to both include and exclude filters).
377    // the real-mode path below only needs `traversed_only` because the subsequent `remove_dir`
378    // call handles the non-empty case via ENOTEMPTY.
379    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    // skip directories that were only traversed to look for include matches.
394    // not needed for exclude-only filters or directly-matched directories.
395    // non-empty directories are handled by the ENOTEMPTY check below.
396    if traversed_only {
397        tracing::debug!(
398            "directory {:?} had nothing removed, leaving it intact",
399            &path
400        );
401        return Ok(rm_summary);
402    }
403    // when filtering is active, directories may not be empty because we only removed
404    // matching files (includes) or skipped excluded files; use remove_dir (not remove_dir_all)
405    // so non-empty directories fail gracefully with ENOTEMPTY
406    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            // with filtering, it's expected that directories may not be empty because we only
413            // removed matching files; raw_os_error 39 is ENOTEMPTY on Linux
414            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            // change file permissions to not readable and not writable
459            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        // make parent directory read-only (no write permission)
484        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        // should fail with permission denied error
500        assert!(result.is_err());
501        let err = result.unwrap_err();
502        let err_string = format!("{:#}", err);
503        // verify the error chain includes "Permission denied"
504        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        /// Test that path-based patterns (with /) work correctly with nested paths.
515        #[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            // create filter that should only remove files in bar/ directory
521            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            // should only remove files matching bar/*.txt pattern (bar/1.txt, bar/2.txt, bar/3.txt)
534            assert_eq!(
535                summary.files_removed, 3,
536                "should remove 3 files matching bar/*.txt"
537            );
538            // each file is 1 byte ("1", "2", "3")
539            assert_eq!(summary.bytes_removed, 3, "should report 3 bytes removed");
540            // verify the right files were removed
541            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            // verify files outside the pattern still exist
554            assert!(
555                test_path.join("foo/0.txt").exists(),
556                "0.txt should still exist"
557            );
558            Ok(())
559        }
560        /// Test that filters are applied to top-level file arguments.
561        #[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            // create filter that excludes .txt files
567            let mut filter = FilterSettings::new();
568            filter.add_exclude("*.txt").unwrap();
569            let summary = rm(
570                &PROGRESS,
571                &test_path.join("foo/0.txt"), // single file source
572                &Settings {
573                    fail_early: false,
574                    filter: Some(filter),
575                    dry_run: None,
576                },
577            )
578            .await?;
579            // the file should NOT be removed because it matches the exclude pattern
580            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        /// Test that filters apply to root directories with simple exclude patterns.
591        #[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            // create a directory that should be excluded
596            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            // create filter that excludes *_dir/ directories
599            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            // directory should NOT be removed because it matches exclude pattern
612            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        /// Test that filters apply to root symlinks with simple exclude patterns.
623        #[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            // create a target file and a symlink to it
628            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            // create filter that excludes *_link
635            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            // symlink should NOT be removed because it matches exclude pattern
648            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        /// Test combined include and exclude patterns (exclude takes precedence).
659        #[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            // test structure from setup_test_dir:
665            // foo/
666            //   0.txt
667            //   bar/ (1.txt, 2.txt, 3.txt)
668            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
669            // include all .txt files in bar/, but exclude 2.txt specifically
670            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            // should remove: bar/1.txt, bar/3.txt = 2 files
684            // should skip: bar/2.txt (excluded by pattern), 0.txt (excluded by default - no match) = 2 files
685            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            // verify
691            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        /// Test that skipped counts accurately reflect what was filtered.
706        #[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            // test structure from setup_test_dir:
712            // foo/
713            //   0.txt
714            //   bar/ (1.txt, 2.txt, 3.txt)
715            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
716            // exclude bar/ directory entirely
717            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            // removed: 0.txt, baz/4.txt = 2 files
730            // removed: baz/5.txt symlink, baz/6.txt symlink = 2 symlinks
731            // removed: baz = 1 directory (foo cannot be removed because bar still exists)
732            // skipped: bar directory (1 dir) - contents not counted since whole dir skipped
733            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            // bar should still exist
744            assert!(
745                test_path.join("foo/bar").exists(),
746                "bar directory should still exist"
747            );
748            // foo should still exist (not empty because bar is still there)
749            assert!(
750                test_path.join("foo").exists(),
751                "foo directory should still exist (contains bar)"
752            );
753            Ok(())
754        }
755        /// Test that empty directories are not removed when they were only traversed to look
756        /// for matches (regression test for bug where --include='foo' would remove empty dir baz).
757        #[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            // create structure:
762            // test/
763            //   foo (file)
764            //   bar (file)
765            //   baz/ (empty directory)
766            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            // include only 'foo' file
770            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            // only 'foo' should be removed
783            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            // verify foo was removed
789            assert!(!test_path.join("foo").exists(), "foo should be removed");
790            // verify bar still exists (not matching include pattern)
791            assert!(test_path.join("bar").exists(), "bar should still exist");
792            // verify empty baz directory still exists
793            assert!(
794                test_path.join("baz").exists(),
795                "empty baz directory should NOT be removed"
796            );
797            Ok(())
798        }
799        /// Test that empty directories ARE removed with exclude-only filters.
800        /// Unlike include filters (where empty dirs are only traversed for matches),
801        /// exclude-only filters should not prevent removal of empty directories.
802        #[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            // create structure:
807            // test/
808            //   foo (file)
809            //   bar.log (file)
810            //   baz/ (empty directory)
811            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            // exclude only .log files
815            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            // foo should be removed, bar.log should be skipped, baz/ should be removed
828            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        /// Test that empty directories are not removed in dry-run mode when only traversed.
846        #[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            // create structure:
851            // test/
852            //   foo (file)
853            //   bar (file)
854            //   baz/ (empty directory)
855            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            // include only 'foo' file
859            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            // only 'foo' should be reported as would-be-removed
872            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            // verify nothing was actually removed (dry-run mode)
881            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        /// Test that an empty directory directly matching an include pattern IS removed.
887        /// Unlike traversed-only directories, directly matched ones are explicit targets.
888        #[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            // create structure:
893            // test/
894            //   foo (file)
895            //   baz/ (empty directory)
896            tokio::fs::write(test_path.join("foo"), "content").await?;
897            tokio::fs::create_dir(test_path.join("baz")).await?;
898            // include pattern that directly matches the directory
899            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        /// Test that dry-run mode doesn't modify permissions on read-only directories.
927        #[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            // make the directory read-only
934            tokio::fs::set_permissions(&readonly_dir, std::fs::Permissions::from_mode(0o555))
935                .await?;
936            // verify it's read-only
937            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            // verify the directory still exists (dry-run shouldn't remove it)
957            assert!(
958                readonly_dir.exists(),
959                "directory should still exist after dry-run"
960            );
961            // verify permissions weren't changed
962            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            // verify summary shows what would be removed
972            assert!(
973                summary.directories_removed > 0 || summary.files_removed > 0,
974                "dry-run should report what would be removed"
975            );
976            Ok(())
977        }
978        /// Test that dry-run mode with filtering correctly handles directories that
979        /// wouldn't be empty after filtering.
980        #[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            // test structure from setup_test_dir:
986            // foo/
987            //   0.txt
988            //   bar/ (1.txt, 2.txt, 3.txt)
989            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
990            // exclude bar/ - so foo would not be empty after removing (bar still there)
991            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            // dry-run shouldn't actually remove anything
1004            assert!(
1005                test_path.join("foo").exists(),
1006                "foo should still exist after dry-run"
1007            );
1008            // verify summary reflects what WOULD happen:
1009            // - files: 0.txt, baz/4.txt would be removed = 2
1010            // - symlinks: baz/5.txt, baz/6.txt would be removed = 2
1011            // - directories: baz would be removed, but NOT foo (bar is skipped, so foo not empty)
1012            // - skipped: bar directory = 1
1013            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        /// Test that dry-run with exclude-only filter correctly reports empty directories
1032        /// as would-be-removed (unlike include filters where empty dirs are only traversed).
1033        #[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            // create structure:
1039            // test/
1040            //   foo (file)
1041            //   bar.log (file)
1042            //   baz/ (empty directory)
1043            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            // exclude only .log files
1047            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            // foo should be reported as would-be-removed, bar.log skipped, baz/ removed
1060            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            // verify nothing was actually removed (dry-run mode)
1073            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        /// Test that dry-run correctly reports removal of an empty directory that directly
1082        /// matches an include pattern (not merely traversed).
1083        #[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            // create structure:
1089            // test/
1090            //   foo (file)
1091            //   baz/ (empty directory)
1092            tokio::fs::write(test_path.join("foo"), "content").await?;
1093            tokio::fs::create_dir(test_path.join("baz")).await?;
1094            // include pattern that directly matches the directory
1095            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            // verify nothing was actually removed (dry-run mode)
1113            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}