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