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 / Java / C++ / Swift / .NET**: no-op (their cleanable dirs are dependencies or build outputs not easily preservable)
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
62        | ProjectType::Go
63        | ProjectType::Java
64        | ProjectType::Cpp
65        | ProjectType::Swift
66        | ProjectType::DotNet
67        | ProjectType::Ruby
68        | ProjectType::Elixir
69        | ProjectType::Deno => Ok(Vec::new()),
70    }
71}
72
73/// Preserve Rust executables from `target/release/` and `target/debug/`.
74fn preserve_rust_executables(project: &Project) -> Result<Vec<PreservedExecutable>> {
75    let target_dir = &project.build_arts.path;
76    let bin_dir = project.root_path.join("bin");
77    let mut preserved = Vec::new();
78
79    for profile in &["release", "debug"] {
80        let profile_dir = target_dir.join(profile);
81        if !profile_dir.is_dir() {
82            continue;
83        }
84
85        let dest_dir = bin_dir.join(profile);
86        let executables = find_rust_executables(&profile_dir)?;
87
88        if executables.is_empty() {
89            continue;
90        }
91
92        fs::create_dir_all(&dest_dir)
93            .with_context(|| format!("Failed to create {}", dest_dir.display()))?;
94
95        for exe_path in executables {
96            let file_name = exe_path
97                .file_name()
98                .expect("executable path should have a file name");
99            let dest_path = dest_dir.join(file_name);
100
101            fs::copy(&exe_path, &dest_path).with_context(|| {
102                format!(
103                    "Failed to copy {} to {}",
104                    exe_path.display(),
105                    dest_path.display()
106                )
107            })?;
108
109            preserved.push(PreservedExecutable {
110                source: exe_path,
111                destination: dest_path,
112            });
113        }
114    }
115
116    Ok(preserved)
117}
118
119/// Find executable files in a Rust profile directory (e.g. `target/release/`).
120///
121/// Returns files that pass [`is_executable`] and are not build metadata
122/// (excludes `.d`, `.rmeta`, `.rlib`, `.a`, `.so`, `.dylib`, `.dll`, `.pdb`
123/// extensions).
124fn find_rust_executables(profile_dir: &Path) -> Result<Vec<PathBuf>> {
125    let mut executables = Vec::new();
126
127    let entries = fs::read_dir(profile_dir)
128        .with_context(|| format!("Failed to read {}", profile_dir.display()))?;
129
130    for entry in entries {
131        let entry = entry?;
132        let path = entry.path();
133
134        if !path.is_file() {
135            continue;
136        }
137
138        // Skip files with excluded extensions
139        if let Some(ext) = path.extension().and_then(|e| e.to_str())
140            && RUST_EXCLUDED_EXTENSIONS.contains(&ext)
141        {
142            continue;
143        }
144
145        // Check if file is executable
146        let metadata = path.metadata()?;
147        if is_executable(&path, &metadata) {
148            executables.push(path);
149        }
150    }
151
152    Ok(executables)
153}
154
155/// Preserve Python build outputs: `.whl` from `dist/` and C extensions from `build/`.
156fn preserve_python_executables(project: &Project) -> Result<Vec<PreservedExecutable>> {
157    let root = &project.root_path;
158    let bin_dir = root.join("bin");
159    let mut preserved = Vec::new();
160
161    collect_wheel_files(&root.join("dist"), &bin_dir, &mut preserved)?;
162    collect_native_extensions(&root.join("build"), &bin_dir, &mut preserved)?;
163
164    Ok(preserved)
165}
166
167/// Copy `.whl` wheel files from the `dist/` directory into `bin_dir`.
168fn collect_wheel_files(
169    dist_dir: &Path,
170    bin_dir: &Path,
171    preserved: &mut Vec<PreservedExecutable>,
172) -> Result<()> {
173    if !dist_dir.is_dir() {
174        return Ok(());
175    }
176
177    let Ok(entries) = fs::read_dir(dist_dir) else {
178        return Ok(());
179    };
180
181    for entry in entries.flatten() {
182        let path = entry.path();
183        if path.extension().and_then(|e| e.to_str()) == Some("whl") {
184            copy_to_bin(&path, bin_dir, preserved)?;
185        }
186    }
187
188    Ok(())
189}
190
191/// Recursively copy `.so` / `.pyd` C extension files from the `build/` directory into `bin_dir`.
192fn collect_native_extensions(
193    build_dir: &Path,
194    bin_dir: &Path,
195    preserved: &mut Vec<PreservedExecutable>,
196) -> Result<()> {
197    if !build_dir.is_dir() {
198        return Ok(());
199    }
200
201    for entry in walkdir::WalkDir::new(build_dir)
202        .into_iter()
203        .filter_map(std::result::Result::ok)
204    {
205        let path = entry.path();
206        if !path.is_file() {
207            continue;
208        }
209
210        let is_native_ext = path
211            .extension()
212            .and_then(|e| e.to_str())
213            .is_some_and(|ext| ext == "so" || ext == "pyd");
214
215        if is_native_ext {
216            copy_to_bin(path, bin_dir, preserved)?;
217        }
218    }
219
220    Ok(())
221}
222
223/// Copy a single file into `bin_dir`, creating the directory if needed,
224/// and record it as a [`PreservedExecutable`].
225fn copy_to_bin(
226    source: &Path,
227    bin_dir: &Path,
228    preserved: &mut Vec<PreservedExecutable>,
229) -> Result<()> {
230    fs::create_dir_all(bin_dir)
231        .with_context(|| format!("Failed to create {}", bin_dir.display()))?;
232
233    let file_name = source
234        .file_name()
235        .expect("source path should have a file name");
236    let dest_path = bin_dir.join(file_name);
237
238    fs::copy(source, &dest_path).with_context(|| {
239        format!(
240            "Failed to copy {} to {}",
241            source.display(),
242            dest_path.display()
243        )
244    })?;
245
246    preserved.push(PreservedExecutable {
247        source: source.to_path_buf(),
248        destination: dest_path,
249    });
250
251    Ok(())
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::project::BuildArtifacts;
258    use tempfile::TempDir;
259
260    fn create_test_project(tmp: &TempDir, kind: ProjectType) -> Project {
261        let root = tmp.path().to_path_buf();
262        let build_dir = match kind {
263            ProjectType::Rust | ProjectType::Java => root.join("target"),
264            ProjectType::Python => root.join("__pycache__"),
265            ProjectType::Node | ProjectType::Deno => root.join("node_modules"),
266            ProjectType::Go | ProjectType::Ruby => root.join("vendor"),
267            ProjectType::Cpp => root.join("build"),
268            ProjectType::Swift => root.join(".build"),
269            ProjectType::DotNet => root.join("obj"),
270            ProjectType::Elixir => root.join("_build"),
271        };
272
273        fs::create_dir_all(&build_dir).unwrap();
274
275        Project::new(
276            kind,
277            root,
278            BuildArtifacts {
279                path: build_dir,
280                size: 0,
281            },
282            Some("test-project".to_string()),
283        )
284    }
285
286    #[test]
287    #[cfg(unix)]
288    fn test_preserve_rust_executables_unix() {
289        use std::os::unix::fs::PermissionsExt;
290
291        let tmp = TempDir::new().unwrap();
292        let project = create_test_project(&tmp, ProjectType::Rust);
293
294        // Create target/release/ with an executable and a metadata file
295        let release_dir = tmp.path().join("target/release");
296        fs::create_dir_all(&release_dir).unwrap();
297
298        let exe_path = release_dir.join("my-binary");
299        fs::write(&exe_path, b"fake binary").unwrap();
300        fs::set_permissions(&exe_path, fs::Permissions::from_mode(0o755)).unwrap();
301
302        let dep_file = release_dir.join("my-binary.d");
303        fs::write(&dep_file, b"dep info").unwrap();
304
305        let result = preserve_executables(&project).unwrap();
306
307        assert_eq!(result.len(), 1);
308        assert_eq!(
309            result[0].destination,
310            tmp.path().join("bin/release/my-binary")
311        );
312        assert!(result[0].destination.exists());
313    }
314
315    #[test]
316    #[cfg(windows)]
317    fn test_preserve_rust_executables_windows() {
318        let tmp = TempDir::new().unwrap();
319        let project = create_test_project(&tmp, ProjectType::Rust);
320
321        let release_dir = tmp.path().join("target/release");
322        fs::create_dir_all(&release_dir).unwrap();
323
324        // On Windows, executables have the .exe extension
325        let exe_path = release_dir.join("my-binary.exe");
326        fs::write(&exe_path, b"fake binary").unwrap();
327
328        let dep_file = release_dir.join("my-binary.d");
329        fs::write(&dep_file, b"dep info").unwrap();
330
331        let result = preserve_executables(&project).unwrap();
332
333        assert_eq!(result.len(), 1);
334        assert_eq!(
335            result[0].destination,
336            tmp.path().join("bin/release/my-binary.exe")
337        );
338        assert!(result[0].destination.exists());
339    }
340
341    #[test]
342    #[cfg(unix)]
343    fn test_preserve_rust_skips_non_executable_unix() {
344        use std::os::unix::fs::PermissionsExt;
345
346        let tmp = TempDir::new().unwrap();
347        let project = create_test_project(&tmp, ProjectType::Rust);
348
349        let release_dir = tmp.path().join("target/release");
350        fs::create_dir_all(&release_dir).unwrap();
351
352        // Non-executable file (mode 0o644)
353        let non_exe = release_dir.join("some-file");
354        fs::write(&non_exe, b"not executable").unwrap();
355        fs::set_permissions(&non_exe, fs::Permissions::from_mode(0o644)).unwrap();
356
357        let result = preserve_executables(&project).unwrap();
358        assert!(result.is_empty());
359    }
360
361    #[test]
362    #[cfg(windows)]
363    fn test_preserve_rust_skips_non_executable_windows() {
364        let tmp = TempDir::new().unwrap();
365        let project = create_test_project(&tmp, ProjectType::Rust);
366
367        let release_dir = tmp.path().join("target/release");
368        fs::create_dir_all(&release_dir).unwrap();
369
370        // On Windows, a file without .exe extension is not treated as executable
371        let non_exe = release_dir.join("some-file.txt");
372        fs::write(&non_exe, b"not executable").unwrap();
373
374        let result = preserve_executables(&project).unwrap();
375        assert!(result.is_empty());
376    }
377
378    #[test]
379    fn test_node_is_noop() {
380        let tmp = TempDir::new().unwrap();
381        let project = create_test_project(&tmp, ProjectType::Node);
382
383        let result = preserve_executables(&project).unwrap();
384        assert!(result.is_empty());
385    }
386
387    #[test]
388    fn test_go_is_noop() {
389        let tmp = TempDir::new().unwrap();
390        let project = create_test_project(&tmp, ProjectType::Go);
391
392        let result = preserve_executables(&project).unwrap();
393        assert!(result.is_empty());
394    }
395
396    #[test]
397    fn test_preserve_rust_no_profile_dirs() {
398        let tmp = TempDir::new().unwrap();
399        let project = create_test_project(&tmp, ProjectType::Rust);
400
401        // target/ exists but no release/ or debug/ subdirs
402        let result = preserve_executables(&project).unwrap();
403        assert!(result.is_empty());
404        assert!(!tmp.path().join("bin").exists());
405    }
406
407    // ── Unix-specific tests ─────────────────────────────────────────────
408
409    #[test]
410    #[cfg(unix)]
411    fn test_find_multiple_rust_executables_unix() {
412        use std::os::unix::fs::PermissionsExt;
413
414        let tmp = TempDir::new().unwrap();
415        let project = create_test_project(&tmp, ProjectType::Rust);
416
417        let release_dir = tmp.path().join("target/release");
418        fs::create_dir_all(&release_dir).unwrap();
419
420        // Create multiple executables
421        for name in &["binary-a", "binary-b", "binary-c"] {
422            let exe_path = release_dir.join(name);
423            fs::write(&exe_path, b"fake binary").unwrap();
424            fs::set_permissions(&exe_path, fs::Permissions::from_mode(0o755)).unwrap();
425        }
426
427        let result = preserve_executables(&project).unwrap();
428        assert_eq!(result.len(), 3);
429
430        for preserved in &result {
431            assert!(preserved.destination.exists());
432            assert!(
433                preserved
434                    .destination
435                    .starts_with(tmp.path().join("bin/release"))
436            );
437        }
438    }
439
440    #[test]
441    #[cfg(unix)]
442    fn test_find_rust_executables_excludes_metadata_even_if_executable_unix() {
443        use std::os::unix::fs::PermissionsExt;
444
445        let tmp = TempDir::new().unwrap();
446        let project = create_test_project(&tmp, ProjectType::Rust);
447
448        let release_dir = tmp.path().join("target/release");
449        fs::create_dir_all(&release_dir).unwrap();
450
451        // Create files with excluded extensions but with executable permissions
452        let excluded_files = [
453            "dep.d",
454            "lib.rmeta",
455            "lib.rlib",
456            "archive.a",
457            "shared.so",
458            "shared.dylib",
459            "shared.dll",
460            "debug.pdb",
461        ];
462
463        for name in &excluded_files {
464            let file_path = release_dir.join(name);
465            fs::write(&file_path, b"fake content").unwrap();
466            fs::set_permissions(&file_path, fs::Permissions::from_mode(0o755)).unwrap();
467        }
468
469        // Also add a real executable to make sure it IS found
470        let exe_path = release_dir.join("real-binary");
471        fs::write(&exe_path, b"real binary").unwrap();
472        fs::set_permissions(&exe_path, fs::Permissions::from_mode(0o755)).unwrap();
473
474        let result = preserve_executables(&project).unwrap();
475        assert_eq!(result.len(), 1);
476        assert!(
477            result[0]
478                .destination
479                .file_name()
480                .unwrap()
481                .to_str()
482                .unwrap()
483                .contains("real-binary")
484        );
485    }
486
487    #[test]
488    #[cfg(unix)]
489    fn test_is_executable_permission_variants_unix() {
490        use std::os::unix::fs::PermissionsExt;
491
492        let tmp = TempDir::new().unwrap();
493
494        // Test user-only execute (0o100)
495        let user_exe = tmp.path().join("user_exe");
496        fs::write(&user_exe, b"content").unwrap();
497        fs::set_permissions(&user_exe, fs::Permissions::from_mode(0o700)).unwrap();
498        let meta = user_exe.metadata().unwrap();
499        assert!(is_executable(&user_exe, &meta));
500
501        // Test group-only execute (0o010)
502        let group_exe = tmp.path().join("group_exe");
503        fs::write(&group_exe, b"content").unwrap();
504        fs::set_permissions(&group_exe, fs::Permissions::from_mode(0o070)).unwrap();
505        let meta = group_exe.metadata().unwrap();
506        assert!(is_executable(&group_exe, &meta));
507
508        // Test other-only execute (0o001)
509        let other_exe = tmp.path().join("other_exe");
510        fs::write(&other_exe, b"content").unwrap();
511        fs::set_permissions(&other_exe, fs::Permissions::from_mode(0o601)).unwrap();
512        let meta = other_exe.metadata().unwrap();
513        assert!(is_executable(&other_exe, &meta));
514
515        // Test no execute at all (0o644)
516        let no_exe = tmp.path().join("no_exe");
517        fs::write(&no_exe, b"content").unwrap();
518        fs::set_permissions(&no_exe, fs::Permissions::from_mode(0o644)).unwrap();
519        let meta = no_exe.metadata().unwrap();
520        assert!(!is_executable(&no_exe, &meta));
521    }
522
523    #[test]
524    #[cfg(unix)]
525    fn test_preserve_rust_debug_and_release_unix() {
526        use std::os::unix::fs::PermissionsExt;
527
528        let tmp = TempDir::new().unwrap();
529        let project = create_test_project(&tmp, ProjectType::Rust);
530
531        // Create executables in both debug and release
532        for profile in &["debug", "release"] {
533            let profile_dir = tmp.path().join("target").join(profile);
534            fs::create_dir_all(&profile_dir).unwrap();
535
536            let exe_path = profile_dir.join("my-binary");
537            fs::write(&exe_path, b"fake binary").unwrap();
538            fs::set_permissions(&exe_path, fs::Permissions::from_mode(0o755)).unwrap();
539        }
540
541        let result = preserve_executables(&project).unwrap();
542        assert_eq!(result.len(), 2);
543
544        // Verify both profiles have preserved executables
545        let dest_names: Vec<_> = result
546            .iter()
547            .map(|p| p.destination.to_string_lossy().to_string())
548            .collect();
549
550        assert!(dest_names.iter().any(|d| d.contains("bin/release")));
551        assert!(dest_names.iter().any(|d| d.contains("bin/debug")));
552    }
553
554    #[test]
555    #[cfg(unix)]
556    fn test_preserve_python_so_extensions_unix() {
557        let tmp = TempDir::new().unwrap();
558        let project = create_test_project(&tmp, ProjectType::Python);
559
560        // Create a build/ directory with .so extensions
561        let build_dir = tmp.path().join("build/lib.linux-x86_64-3.9");
562        fs::create_dir_all(&build_dir).unwrap();
563
564        fs::write(
565            build_dir.join("mymodule.cpython-39-x86_64-linux-gnu.so"),
566            b"shared object",
567        )
568        .unwrap();
569        fs::write(build_dir.join("another.so"), b"shared object").unwrap();
570
571        let result = preserve_python_executables(&project).unwrap();
572        assert_eq!(result.len(), 2);
573
574        for preserved in &result {
575            assert!(preserved.destination.exists());
576            assert!(preserved.destination.starts_with(tmp.path().join("bin")));
577        }
578    }
579
580    // ── Windows-specific tests ──────────────────────────────────────────
581
582    #[test]
583    #[cfg(windows)]
584    fn test_is_executable_case_insensitive_exe_windows() {
585        let tmp = TempDir::new().unwrap();
586
587        // .exe
588        let exe = tmp.path().join("app.exe");
589        fs::write(&exe, b"content").unwrap();
590        let meta = exe.metadata().unwrap();
591        assert!(is_executable(&exe, &meta));
592
593        // .EXE
594        let exe_upper = tmp.path().join("app.EXE");
595        fs::write(&exe_upper, b"content").unwrap();
596        let meta = exe_upper.metadata().unwrap();
597        assert!(is_executable(&exe_upper, &meta));
598
599        // .Exe
600        let exe_mixed = tmp.path().join("app.Exe");
601        fs::write(&exe_mixed, b"content").unwrap();
602        let meta = exe_mixed.metadata().unwrap();
603        assert!(is_executable(&exe_mixed, &meta));
604
605        // Not an exe
606        let not_exe = tmp.path().join("app.txt");
607        fs::write(&not_exe, b"content").unwrap();
608        let meta = not_exe.metadata().unwrap();
609        assert!(!is_executable(&not_exe, &meta));
610
611        // No extension
612        let no_ext = tmp.path().join("app");
613        fs::write(&no_ext, b"content").unwrap();
614        let meta = no_ext.metadata().unwrap();
615        assert!(!is_executable(&no_ext, &meta));
616    }
617
618    #[test]
619    #[cfg(windows)]
620    fn test_preserve_rust_debug_and_release_windows() {
621        let tmp = TempDir::new().unwrap();
622        let project = create_test_project(&tmp, ProjectType::Rust);
623
624        for profile in &["debug", "release"] {
625            let profile_dir = tmp.path().join("target").join(profile);
626            fs::create_dir_all(&profile_dir).unwrap();
627
628            let exe_path = profile_dir.join("my-binary.exe");
629            fs::write(&exe_path, b"fake binary").unwrap();
630        }
631
632        let result = preserve_executables(&project).unwrap();
633        assert_eq!(result.len(), 2);
634
635        let dest_names: Vec<_> = result
636            .iter()
637            .map(|p| p.destination.to_string_lossy().to_string())
638            .collect();
639
640        assert!(dest_names.iter().any(|d| d.contains("release")));
641        assert!(dest_names.iter().any(|d| d.contains("debug")));
642    }
643
644    #[test]
645    #[cfg(windows)]
646    fn test_find_rust_executables_excludes_metadata_windows() {
647        let tmp = TempDir::new().unwrap();
648        let project = create_test_project(&tmp, ProjectType::Rust);
649
650        let release_dir = tmp.path().join("target/release");
651        fs::create_dir_all(&release_dir).unwrap();
652
653        // Files with excluded extensions should be skipped
654        fs::write(release_dir.join("dep.d"), b"dep info").unwrap();
655        fs::write(release_dir.join("lib.dll"), b"library").unwrap();
656        fs::write(release_dir.join("debug.pdb"), b"symbols").unwrap();
657        fs::write(release_dir.join("lib.rlib"), b"rust lib").unwrap();
658
659        // Only .exe should be found
660        fs::write(release_dir.join("my-binary.exe"), b"real binary").unwrap();
661
662        let result = preserve_executables(&project).unwrap();
663        assert_eq!(result.len(), 1);
664        assert!(
665            result[0]
666                .destination
667                .file_name()
668                .unwrap()
669                .to_str()
670                .unwrap()
671                .contains("my-binary.exe")
672        );
673    }
674
675    #[test]
676    #[cfg(windows)]
677    fn test_find_multiple_rust_executables_windows() {
678        let tmp = TempDir::new().unwrap();
679        let project = create_test_project(&tmp, ProjectType::Rust);
680
681        let release_dir = tmp.path().join("target/release");
682        fs::create_dir_all(&release_dir).unwrap();
683
684        // Create multiple .exe files
685        for name in &["binary-a.exe", "binary-b.exe", "binary-c.exe"] {
686            fs::write(release_dir.join(name), b"fake binary").unwrap();
687        }
688
689        let result = preserve_executables(&project).unwrap();
690        assert_eq!(result.len(), 3);
691    }
692
693    #[test]
694    #[cfg(windows)]
695    fn test_preserve_python_pyd_extensions_windows() {
696        let tmp = TempDir::new().unwrap();
697        let project = create_test_project(&tmp, ProjectType::Python);
698
699        let build_dir = tmp.path().join("build/lib.win-amd64-3.9");
700        fs::create_dir_all(&build_dir).unwrap();
701
702        fs::write(
703            build_dir.join("mymodule.cp39-win_amd64.pyd"),
704            b"python extension",
705        )
706        .unwrap();
707        fs::write(build_dir.join("another.pyd"), b"python extension").unwrap();
708
709        let result = preserve_python_executables(&project).unwrap();
710        assert_eq!(result.len(), 2);
711
712        for preserved in &result {
713            assert!(preserved.destination.exists());
714        }
715    }
716
717    // ── Cross-platform tests (run on all OS) ────────────────────────────
718
719    #[test]
720    fn test_preserve_python_whl_files() {
721        let tmp = TempDir::new().unwrap();
722        let project = create_test_project(&tmp, ProjectType::Python);
723
724        // Create dist/ with .whl files
725        let dist_dir = tmp.path().join("dist");
726        fs::create_dir_all(&dist_dir).unwrap();
727
728        fs::write(
729            dist_dir.join("mypackage-1.0.0-py3-none-any.whl"),
730            b"wheel content",
731        )
732        .unwrap();
733        fs::write(dist_dir.join("mypackage-1.0.0.tar.gz"), b"tarball content").unwrap();
734
735        let result = preserve_python_executables(&project).unwrap();
736        // Only .whl should be preserved, not .tar.gz
737        assert_eq!(result.len(), 1);
738        assert!(
739            result[0]
740                .destination
741                .extension()
742                .is_some_and(|ext| ext.eq_ignore_ascii_case("whl"))
743        );
744    }
745
746    #[test]
747    fn test_preserve_python_no_dist_no_build() {
748        let tmp = TempDir::new().unwrap();
749        let project = create_test_project(&tmp, ProjectType::Python);
750
751        // No dist/ or build/ dirs exist
752        let result = preserve_python_executables(&project).unwrap();
753        assert!(result.is_empty());
754    }
755
756    #[test]
757    fn test_preserve_python_empty_dist_and_build() {
758        let tmp = TempDir::new().unwrap();
759        let project = create_test_project(&tmp, ProjectType::Python);
760
761        // Create empty dist/ and build/ directories
762        fs::create_dir_all(tmp.path().join("dist")).unwrap();
763        fs::create_dir_all(tmp.path().join("build")).unwrap();
764
765        let result = preserve_python_executables(&project).unwrap();
766        assert!(result.is_empty());
767    }
768
769    #[test]
770    fn test_preserve_python_whl_and_extensions_combined() {
771        let tmp = TempDir::new().unwrap();
772        let project = create_test_project(&tmp, ProjectType::Python);
773
774        // Create dist/ with .whl files
775        let dist_dir = tmp.path().join("dist");
776        fs::create_dir_all(&dist_dir).unwrap();
777        fs::write(dist_dir.join("mypackage-1.0.0-py3-none-any.whl"), b"wheel").unwrap();
778
779        // Create build/ with extensions (.so on Unix, .pyd on Windows)
780        let build_dir = tmp.path().join("build/lib");
781        fs::create_dir_all(&build_dir).unwrap();
782
783        #[cfg(unix)]
784        fs::write(build_dir.join("native.so"), b"shared object").unwrap();
785
786        #[cfg(windows)]
787        fs::write(build_dir.join("native.pyd"), b"python extension").unwrap();
788
789        let result = preserve_python_executables(&project).unwrap();
790        // Should find both the .whl and the platform-specific extension
791        assert_eq!(result.len(), 2);
792    }
793
794    #[test]
795    fn test_preserve_executables_returns_correct_source_paths() {
796        let tmp = TempDir::new().unwrap();
797        let project = create_test_project(&tmp, ProjectType::Python);
798
799        let dist_dir = tmp.path().join("dist");
800        fs::create_dir_all(&dist_dir).unwrap();
801        let whl_path = dist_dir.join("pkg-1.0-py3-none-any.whl");
802        fs::write(&whl_path, b"wheel content").unwrap();
803
804        let result = preserve_python_executables(&project).unwrap();
805        assert_eq!(result.len(), 1);
806        assert_eq!(result[0].source, whl_path);
807        assert_eq!(
808            result[0].destination,
809            tmp.path().join("bin/pkg-1.0-py3-none-any.whl")
810        );
811    }
812}