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