Skip to main content

aster_forge_utils/
fs.rs

1//! Best-effort filesystem cleanup helpers.
2//!
3//! These helpers are for temporary artifacts where cleanup failures should be logged without
4//! masking the primary operation result. They intentionally do not return errors. Callers that need
5//! transactional deletion, user-visible failures, or storage-driver semantics should keep using
6//! explicit filesystem or storage APIs at the product boundary.
7
8use std::io::ErrorKind;
9use std::path::Path;
10use std::time::Duration;
11
12const CLEANUP_RETRY_ATTEMPTS: usize = 3;
13const CLEANUP_RETRY_DELAY: Duration = Duration::from_millis(50);
14
15/// Removes a temporary file, ignoring missing files and logging other failures.
16pub async fn cleanup_temp_file(path: impl AsRef<Path>) {
17    let path = path.as_ref();
18    if let Err(error) = tokio::fs::remove_file(path).await
19        && error.kind() != ErrorKind::NotFound
20    {
21        tracing::warn!(
22            path = %path.display(),
23            error = %error,
24            "failed to cleanup temp file"
25        );
26    }
27}
28
29/// Removes a temporary directory tree, ignoring missing directories and logging other failures.
30///
31/// `DirectoryNotEmpty` is retried because some platforms and filesystem watchers can briefly
32/// create files while a recursive removal is in progress.
33pub async fn cleanup_temp_dir(path: impl AsRef<Path>) {
34    let path = path.as_ref();
35    for _ in 0..CLEANUP_RETRY_ATTEMPTS {
36        match tokio::fs::remove_dir_all(path).await {
37            Ok(()) => return,
38            Err(error) if error.kind() == ErrorKind::NotFound => return,
39            Err(error) if error.kind() == ErrorKind::DirectoryNotEmpty => {
40                tokio::time::sleep(CLEANUP_RETRY_DELAY).await;
41            }
42            Err(error) => {
43                tracing::warn!(
44                    path = %path.display(),
45                    error = %error,
46                    "failed to cleanup temp dir"
47                );
48                return;
49            }
50        }
51    }
52
53    if let Err(error) = tokio::fs::remove_dir_all(path).await
54        && error.kind() != ErrorKind::NotFound
55    {
56        tracing::warn!(
57            path = %path.display(),
58            error = %error,
59            "failed to cleanup temp dir"
60        );
61    }
62}
63
64/// Removes the short-lived runtime temporary directory under `temp_root`.
65pub async fn cleanup_runtime_temp_root(temp_root: &str) {
66    cleanup_temp_dir(crate::paths::runtime_temp_dir(temp_root)).await;
67}
68
69#[cfg(test)]
70mod tests {
71    use std::path::PathBuf;
72    use std::sync::atomic::{AtomicU64, Ordering};
73
74    use super::{cleanup_runtime_temp_root, cleanup_temp_dir, cleanup_temp_file};
75
76    static TEMP_ID: AtomicU64 = AtomicU64::new(0);
77
78    fn unique_temp_path(label: &str) -> PathBuf {
79        std::env::temp_dir().join(format!(
80            "aster-forge-utils-{label}-{}-{}",
81            std::process::id(),
82            TEMP_ID.fetch_add(1, Ordering::Relaxed)
83        ))
84    }
85
86    #[tokio::test]
87    async fn cleanup_temp_file_removes_file() {
88        let path = unique_temp_path("file-cleanup");
89        tokio::fs::write(&path, b"temporary")
90            .await
91            .expect("temp file should be created");
92
93        cleanup_temp_file(&path).await;
94
95        assert!(!path.exists());
96    }
97
98    #[tokio::test]
99    async fn cleanup_temp_file_tolerates_missing_file() {
100        let path = unique_temp_path("missing-file-cleanup");
101
102        cleanup_temp_file(&path).await;
103
104        assert!(!path.exists());
105    }
106
107    #[tokio::test]
108    async fn cleanup_temp_dir_removes_directory_tree() {
109        let path = unique_temp_path("dir-cleanup");
110        let nested = path.join("nested");
111        tokio::fs::create_dir_all(&nested)
112            .await
113            .expect("nested temp dir should be created");
114        tokio::fs::write(nested.join("payload.txt"), b"temporary")
115            .await
116            .expect("nested temp file should be created");
117
118        cleanup_temp_dir(&path).await;
119
120        assert!(!path.exists());
121    }
122
123    #[tokio::test]
124    async fn cleanup_temp_dir_tolerates_missing_directory() {
125        let path = unique_temp_path("missing-dir-cleanup");
126
127        cleanup_temp_dir(&path).await;
128
129        assert!(!path.exists());
130    }
131
132    #[tokio::test]
133    async fn cleanup_runtime_temp_root_removes_runtime_namespace_only() {
134        let root = unique_temp_path("runtime-cleanup");
135        let runtime = crate::paths::runtime_temp_dir(root.to_str().expect("path should be utf-8"));
136        let keep = root.join("tasks");
137        tokio::fs::create_dir_all(&runtime)
138            .await
139            .expect("runtime temp dir should be created");
140        tokio::fs::create_dir_all(&keep)
141            .await
142            .expect("task temp dir should be created");
143
144        cleanup_runtime_temp_root(root.to_str().expect("path should be utf-8")).await;
145
146        assert!(!PathBuf::from(runtime).exists());
147        assert!(keep.is_dir());
148        cleanup_temp_dir(root).await;
149    }
150}