common/
rm.rs

1use anyhow::{anyhow, Context};
2use async_recursion::async_recursion;
3use std::os::unix::fs::PermissionsExt;
4use tracing::instrument;
5
6use crate::progress;
7
8/// Error type for remove operations that preserves operation summary even on failure.
9///
10/// # Logging Convention
11/// When logging this error, use `{:#}` or `{:?}` format to preserve the error chain:
12/// ```ignore
13/// tracing::error!("operation failed: {:#}", &error); // ✅ Shows full chain
14/// tracing::error!("operation failed: {:?}", &error); // ✅ Shows full chain
15/// ```
16/// The Display implementation also shows the full chain, but workspace linting enforces `{:#}`
17/// for consistency.
18#[derive(Debug, thiserror::Error)]
19#[error("{source:#}")]
20pub struct Error {
21    #[source]
22    pub source: anyhow::Error,
23    pub summary: Summary,
24}
25
26impl Error {
27    fn new(source: anyhow::Error, summary: Summary) -> Self {
28        Error { source, summary }
29    }
30}
31
32#[derive(Debug, Clone)]
33pub struct Settings {
34    pub fail_early: bool,
35}
36
37#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
38pub struct Summary {
39    pub files_removed: usize,
40    pub symlinks_removed: usize,
41    pub directories_removed: usize,
42}
43
44impl std::ops::Add for Summary {
45    type Output = Self;
46    fn add(self, other: Self) -> Self {
47        Self {
48            files_removed: self.files_removed + other.files_removed,
49            symlinks_removed: self.symlinks_removed + other.symlinks_removed,
50            directories_removed: self.directories_removed + other.directories_removed,
51        }
52    }
53}
54
55impl std::fmt::Display for Summary {
56    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
57        write!(
58            f,
59            "files removed: {}\n\
60            symlinks removed: {}\n\
61            directories removed: {}\n",
62            self.files_removed, self.symlinks_removed, self.directories_removed
63        )
64    }
65}
66
67#[instrument(skip(prog_track))]
68#[async_recursion]
69pub async fn rm(
70    prog_track: &'static progress::Progress,
71    path: &std::path::Path,
72    settings: &Settings,
73) -> Result<Summary, Error> {
74    let _ops_guard = prog_track.ops.guard();
75    tracing::debug!("read path metadata");
76    let src_metadata = tokio::fs::symlink_metadata(path)
77        .await
78        .with_context(|| format!("failed reading metadata from {:?}", &path))
79        .map_err(|err| Error::new(err, Default::default()))?;
80    if !src_metadata.is_dir() {
81        tracing::debug!("not a directory, just remove");
82        tokio::fs::remove_file(path)
83            .await
84            .with_context(|| format!("failed removing {:?}", &path))
85            .map_err(|err| Error::new(err, Default::default()))?;
86        if src_metadata.file_type().is_symlink() {
87            prog_track.symlinks_removed.inc();
88            return Ok(Summary {
89                symlinks_removed: 1,
90                ..Default::default()
91            });
92        }
93        prog_track.files_removed.inc();
94        return Ok(Summary {
95            files_removed: 1,
96            ..Default::default()
97        });
98    }
99    tracing::debug!("remove contents of the directory first");
100    if src_metadata.permissions().readonly() {
101        tracing::debug!("directory is read-only - change the permissions");
102        tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o777))
103            .await
104            .with_context(|| {
105                format!(
106                    "failed to make '{:?}' directory readable and writeable",
107                    &path
108                )
109            })
110            .map_err(|err| Error::new(err, Default::default()))?;
111    }
112    let mut entries = tokio::fs::read_dir(path)
113        .await
114        .with_context(|| format!("failed reading directory {:?}", &path))
115        .map_err(|err| Error::new(err, Default::default()))?;
116    let mut join_set = tokio::task::JoinSet::new();
117    let mut success = true;
118    while let Some(entry) = entries
119        .next_entry()
120        .await
121        .with_context(|| format!("failed traversing directory {:?}", &path))
122        .map_err(|err| Error::new(err, Default::default()))?
123    {
124        // it's better to await the token here so that we throttle the syscalls generated by the
125        // DirEntry call. the ops-throttle will never cause a deadlock (unlike max-open-files limit)
126        // so it's safe to do here.
127        throttle::get_ops_token().await;
128        let entry_path = entry.path();
129        let settings = settings.clone();
130        let do_rm = || async move { rm(prog_track, &entry_path, &settings).await };
131        join_set.spawn(do_rm());
132    }
133    // unfortunately ReadDir is opening file-descriptors and there's not a good way to limit this,
134    // one thing we CAN do however is to drop it as soon as we're done with it
135    drop(entries);
136    let mut rm_summary = Summary {
137        directories_removed: 0,
138        ..Default::default()
139    };
140    while let Some(res) = join_set.join_next().await {
141        match res.map_err(|err| Error::new(err.into(), Default::default()))? {
142            Ok(summary) => rm_summary = rm_summary + summary,
143            Err(error) => {
144                tracing::error!("remove: {:?} failed with: {:#}", path, &error);
145                rm_summary = rm_summary + error.summary;
146                if settings.fail_early {
147                    return Err(Error::new(error.source, rm_summary));
148                }
149                success = false;
150            }
151        }
152    }
153    if !success {
154        return Err(Error::new(anyhow!("rm: {:?} failed!", &path), rm_summary));
155    }
156    tracing::debug!("finally remove the empty directory");
157    tokio::fs::remove_dir(path)
158        .await
159        .with_context(|| format!("failed removing directory {:?}", &path))
160        .map_err(|err| Error::new(err, rm_summary))?;
161    prog_track.directories_removed.inc();
162    rm_summary.directories_removed += 1;
163    Ok(rm_summary)
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::testutils;
170    use tracing_test::traced_test;
171
172    lazy_static! {
173        static ref PROGRESS: progress::Progress = progress::Progress::new();
174    }
175
176    #[tokio::test]
177    #[traced_test]
178    async fn no_write_permission() -> Result<(), anyhow::Error> {
179        let tmp_dir = testutils::setup_test_dir().await?;
180        let test_path = tmp_dir.as_path();
181        let filepaths = vec![
182            test_path.join("foo").join("0.txt"),
183            test_path.join("foo").join("bar").join("2.txt"),
184            test_path.join("foo").join("baz").join("4.txt"),
185            test_path.join("foo").join("baz"),
186        ];
187        for fpath in &filepaths {
188            // change file permissions to not readable and not writable
189            tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o555)).await?;
190        }
191        let summary = rm(
192            &PROGRESS,
193            &test_path.join("foo"),
194            &Settings { fail_early: false },
195        )
196        .await?;
197        assert!(!test_path.join("foo").exists());
198        assert_eq!(summary.files_removed, 5);
199        assert_eq!(summary.symlinks_removed, 2);
200        assert_eq!(summary.directories_removed, 3);
201        Ok(())
202    }
203
204    #[tokio::test]
205    #[traced_test]
206    async fn parent_dir_no_write_permission() -> Result<(), anyhow::Error> {
207        let tmp_dir = testutils::setup_test_dir().await?;
208        let test_path = tmp_dir.as_path();
209        // make parent directory read-only (no write permission)
210        tokio::fs::set_permissions(
211            &test_path.join("foo").join("bar"),
212            std::fs::Permissions::from_mode(0o555),
213        )
214        .await?;
215        let result = rm(
216            &PROGRESS,
217            &test_path.join("foo").join("bar").join("2.txt"),
218            &Settings { fail_early: true },
219        )
220        .await;
221        // should fail with permission denied error
222        assert!(result.is_err());
223        let err = result.unwrap_err();
224        let err_string = format!("{:#}", err);
225        // verify the error chain includes "Permission denied"
226        assert!(
227            err_string.contains("Permission denied") || err_string.contains("permission denied"),
228            "Error should contain 'Permission denied' but got: {}",
229            err_string
230        );
231        Ok(())
232    }
233}