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