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