Skip to main content

clean_dev_dirs/
executables.rs

1//! Executable preservation logic.
2//!
3//! This module provides functionality to copy compiled executables out of
4//! build directories before they are deleted during cleanup. This allows
5//! users to retain usable binaries while still reclaiming build artifact space.
6
7use std::fs;
8use std::os::unix::fs::PermissionsExt;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result};
12
13use crate::project::{Project, ProjectType};
14
15/// Extensions to exclude when looking for Rust executables.
16const RUST_EXCLUDED_EXTENSIONS: &[&str] = &["d", "rmeta", "rlib", "a", "so", "dylib", "dll", "pdb"];
17
18/// A record of a single preserved executable file.
19#[derive(Debug)]
20pub struct PreservedExecutable {
21    /// Original path inside the build directory
22    pub source: PathBuf,
23    /// Destination path where the file was copied
24    pub destination: PathBuf,
25}
26
27/// Preserve compiled executables from a project's build directory.
28///
29/// Copies executable files to `<project_root>/bin/` before the build
30/// directory is deleted. The behavior depends on the project type:
31///
32/// - **Rust**: copies executables from `target/release/` and `target/debug/`
33/// - **Python**: copies `.whl` files from `dist/` and `.so`/`.pyd` extensions from `build/`
34/// - **Node / Go**: no-op (their cleanable dirs are dependencies, not build outputs)
35///
36/// # Errors
37///
38/// Returns an error if creating destination directories or copying files fails.
39pub fn preserve_executables(project: &Project) -> Result<Vec<PreservedExecutable>> {
40    match project.kind {
41        ProjectType::Rust => preserve_rust_executables(project),
42        ProjectType::Python => preserve_python_executables(project),
43        ProjectType::Node | ProjectType::Go => Ok(Vec::new()),
44    }
45}
46
47/// Preserve Rust executables from `target/release/` and `target/debug/`.
48fn preserve_rust_executables(project: &Project) -> Result<Vec<PreservedExecutable>> {
49    let target_dir = &project.build_arts.path;
50    let bin_dir = project.root_path.join("bin");
51    let mut preserved = Vec::new();
52
53    for profile in &["release", "debug"] {
54        let profile_dir = target_dir.join(profile);
55        if !profile_dir.is_dir() {
56            continue;
57        }
58
59        let dest_dir = bin_dir.join(profile);
60        let executables = find_rust_executables(&profile_dir)?;
61
62        if executables.is_empty() {
63            continue;
64        }
65
66        fs::create_dir_all(&dest_dir)
67            .with_context(|| format!("Failed to create {}", dest_dir.display()))?;
68
69        for exe_path in executables {
70            let file_name = exe_path
71                .file_name()
72                .expect("executable path should have a file name");
73            let dest_path = dest_dir.join(file_name);
74
75            fs::copy(&exe_path, &dest_path).with_context(|| {
76                format!(
77                    "Failed to copy {} to {}",
78                    exe_path.display(),
79                    dest_path.display()
80                )
81            })?;
82
83            preserved.push(PreservedExecutable {
84                source: exe_path,
85                destination: dest_path,
86            });
87        }
88    }
89
90    Ok(preserved)
91}
92
93/// Find executable files in a Rust profile directory (e.g. `target/release/`).
94///
95/// Returns files that are executable (`mode & 0o111 != 0`) and are not
96/// build metadata (excludes `.d`, `.rmeta`, `.rlib`, `.a`, `.so`, `.dylib`,
97/// `.dll`, `.pdb` extensions).
98fn find_rust_executables(profile_dir: &Path) -> Result<Vec<PathBuf>> {
99    let mut executables = Vec::new();
100
101    let entries = fs::read_dir(profile_dir)
102        .with_context(|| format!("Failed to read {}", profile_dir.display()))?;
103
104    for entry in entries {
105        let entry = entry?;
106        let path = entry.path();
107
108        if !path.is_file() {
109            continue;
110        }
111
112        // Skip files with excluded extensions
113        if let Some(ext) = path.extension().and_then(|e| e.to_str())
114            && RUST_EXCLUDED_EXTENSIONS.contains(&ext)
115        {
116            continue;
117        }
118
119        // Check if file is executable
120        let metadata = path.metadata()?;
121        if metadata.permissions().mode() & 0o111 != 0 {
122            executables.push(path);
123        }
124    }
125
126    Ok(executables)
127}
128
129/// Preserve Python build outputs: `.whl` from `dist/` and C extensions from `build/`.
130fn preserve_python_executables(project: &Project) -> Result<Vec<PreservedExecutable>> {
131    let root = &project.root_path;
132    let bin_dir = root.join("bin");
133    let mut preserved = Vec::new();
134
135    // Copy .whl files from dist/
136    let dist_dir = root.join("dist");
137    if dist_dir.is_dir()
138        && let Ok(entries) = fs::read_dir(&dist_dir)
139    {
140        for entry in entries.flatten() {
141            let path = entry.path();
142            if path.extension().and_then(|e| e.to_str()) == Some("whl") {
143                fs::create_dir_all(&bin_dir)
144                    .with_context(|| format!("Failed to create {}", bin_dir.display()))?;
145
146                let file_name = path.file_name().expect("path should have a file name");
147                let dest_path = bin_dir.join(file_name);
148
149                fs::copy(&path, &dest_path).with_context(|| {
150                    format!(
151                        "Failed to copy {} to {}",
152                        path.display(),
153                        dest_path.display()
154                    )
155                })?;
156
157                preserved.push(PreservedExecutable {
158                    source: path,
159                    destination: dest_path,
160                });
161            }
162        }
163    }
164
165    // Copy .so / .pyd C extensions from build/
166    let build_dir = root.join("build");
167    if build_dir.is_dir() {
168        for entry in walkdir::WalkDir::new(&build_dir)
169            .into_iter()
170            .filter_map(std::result::Result::ok)
171        {
172            let path = entry.path();
173            if !path.is_file() {
174                continue;
175            }
176
177            let is_extension = path
178                .extension()
179                .and_then(|e| e.to_str())
180                .is_some_and(|ext| ext == "so" || ext == "pyd");
181
182            if is_extension {
183                fs::create_dir_all(&bin_dir)
184                    .with_context(|| format!("Failed to create {}", bin_dir.display()))?;
185
186                let file_name = path.file_name().expect("path should have a file name");
187                let dest_path = bin_dir.join(file_name);
188
189                fs::copy(path, &dest_path).with_context(|| {
190                    format!(
191                        "Failed to copy {} to {}",
192                        path.display(),
193                        dest_path.display()
194                    )
195                })?;
196
197                preserved.push(PreservedExecutable {
198                    source: path.to_path_buf(),
199                    destination: dest_path,
200                });
201            }
202        }
203    }
204
205    Ok(preserved)
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::project::BuildArtifacts;
212    use std::os::unix::fs::PermissionsExt;
213    use tempfile::TempDir;
214
215    fn create_test_project(tmp: &TempDir, kind: ProjectType) -> Project {
216        let root = tmp.path().to_path_buf();
217        let build_dir = match kind {
218            ProjectType::Rust => root.join("target"),
219            ProjectType::Python => root.join("__pycache__"),
220            ProjectType::Node => root.join("node_modules"),
221            ProjectType::Go => root.join("vendor"),
222        };
223
224        fs::create_dir_all(&build_dir).unwrap();
225
226        Project::new(
227            kind,
228            root,
229            BuildArtifacts {
230                path: build_dir,
231                size: 0,
232            },
233            Some("test-project".to_string()),
234        )
235    }
236
237    #[test]
238    fn test_preserve_rust_executables() {
239        let tmp = TempDir::new().unwrap();
240        let project = create_test_project(&tmp, ProjectType::Rust);
241
242        // Create target/release/ with an executable and a metadata file
243        let release_dir = tmp.path().join("target/release");
244        fs::create_dir_all(&release_dir).unwrap();
245
246        let exe_path = release_dir.join("my-binary");
247        fs::write(&exe_path, b"fake binary").unwrap();
248        fs::set_permissions(&exe_path, fs::Permissions::from_mode(0o755)).unwrap();
249
250        let dep_file = release_dir.join("my-binary.d");
251        fs::write(&dep_file, b"dep info").unwrap();
252
253        let result = preserve_executables(&project).unwrap();
254
255        assert_eq!(result.len(), 1);
256        assert_eq!(
257            result[0].destination,
258            tmp.path().join("bin/release/my-binary")
259        );
260        assert!(result[0].destination.exists());
261    }
262
263    #[test]
264    fn test_preserve_rust_skips_non_executable() {
265        let tmp = TempDir::new().unwrap();
266        let project = create_test_project(&tmp, ProjectType::Rust);
267
268        let release_dir = tmp.path().join("target/release");
269        fs::create_dir_all(&release_dir).unwrap();
270
271        // Non-executable file (mode 0o644)
272        let non_exe = release_dir.join("some-file");
273        fs::write(&non_exe, b"not executable").unwrap();
274        fs::set_permissions(&non_exe, fs::Permissions::from_mode(0o644)).unwrap();
275
276        let result = preserve_executables(&project).unwrap();
277        assert!(result.is_empty());
278    }
279
280    #[test]
281    fn test_node_is_noop() {
282        let tmp = TempDir::new().unwrap();
283        let project = create_test_project(&tmp, ProjectType::Node);
284
285        let result = preserve_executables(&project).unwrap();
286        assert!(result.is_empty());
287    }
288
289    #[test]
290    fn test_go_is_noop() {
291        let tmp = TempDir::new().unwrap();
292        let project = create_test_project(&tmp, ProjectType::Go);
293
294        let result = preserve_executables(&project).unwrap();
295        assert!(result.is_empty());
296    }
297
298    #[test]
299    fn test_preserve_rust_no_profile_dirs() {
300        let tmp = TempDir::new().unwrap();
301        let project = create_test_project(&tmp, ProjectType::Rust);
302
303        // target/ exists but no release/ or debug/ subdirs
304        let result = preserve_executables(&project).unwrap();
305        assert!(result.is_empty());
306        assert!(!tmp.path().join("bin").exists());
307    }
308}