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 Some(file_name) = exe_path.file_name() else {
105                continue;
106            };
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 Some(file_name) = source.file_name() else {
242        return Ok(());
243    };
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) -> anyhow::Result<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)?;
284
285        Ok(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() -> anyhow::Result<()> {
299        use std::os::unix::fs::PermissionsExt;
300
301        let tmp = TempDir::new()?;
302        let project = create_test_project(&tmp, ProjectType::Rust)?;
303
304        let release_dir = tmp.path().join("target/release");
305        fs::create_dir_all(&release_dir)?;
306
307        let exe_path = release_dir.join("my-binary");
308        fs::write(&exe_path, b"fake binary")?;
309        fs::set_permissions(&exe_path, fs::Permissions::from_mode(0o755))?;
310
311        let dep_file = release_dir.join("my-binary.d");
312        fs::write(&dep_file, b"dep info")?;
313
314        let result = preserve_executables(&project)?;
315
316        assert_eq!(result.len(), 1);
317        assert_eq!(
318            result[0].destination,
319            tmp.path().join("bin/release/my-binary")
320        );
321        assert!(result[0].destination.exists());
322
323        Ok(())
324    }
325
326    #[test]
327    #[cfg(windows)]
328    fn test_preserve_rust_executables_windows() -> anyhow::Result<()> {
329        let tmp = TempDir::new()?;
330        let project = create_test_project(&tmp, ProjectType::Rust)?;
331
332        let release_dir = tmp.path().join("target/release");
333        fs::create_dir_all(&release_dir)?;
334
335        let exe_path = release_dir.join("my-binary.exe");
336        fs::write(&exe_path, b"fake binary")?;
337
338        let dep_file = release_dir.join("my-binary.d");
339        fs::write(&dep_file, b"dep info")?;
340
341        let result = preserve_executables(&project)?;
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        Ok(())
351    }
352
353    #[test]
354    #[cfg(unix)]
355    fn test_preserve_rust_skips_non_executable_unix() -> anyhow::Result<()> {
356        use std::os::unix::fs::PermissionsExt;
357
358        let tmp = TempDir::new()?;
359        let project = create_test_project(&tmp, ProjectType::Rust)?;
360
361        let release_dir = tmp.path().join("target/release");
362        fs::create_dir_all(&release_dir)?;
363
364        let non_exe = release_dir.join("some-file");
365        fs::write(&non_exe, b"not executable")?;
366        fs::set_permissions(&non_exe, fs::Permissions::from_mode(0o644))?;
367
368        let result = preserve_executables(&project)?;
369        assert!(result.is_empty());
370
371        Ok(())
372    }
373
374    #[test]
375    #[cfg(windows)]
376    fn test_preserve_rust_skips_non_executable_windows() -> anyhow::Result<()> {
377        let tmp = TempDir::new()?;
378        let project = create_test_project(&tmp, ProjectType::Rust)?;
379
380        let release_dir = tmp.path().join("target/release");
381        fs::create_dir_all(&release_dir)?;
382
383        let non_exe = release_dir.join("some-file.txt");
384        fs::write(&non_exe, b"not executable")?;
385
386        let result = preserve_executables(&project)?;
387        assert!(result.is_empty());
388
389        Ok(())
390    }
391
392    #[test]
393    fn test_node_is_noop() -> anyhow::Result<()> {
394        let tmp = TempDir::new()?;
395        let project = create_test_project(&tmp, ProjectType::Node)?;
396
397        let result = preserve_executables(&project)?;
398        assert!(result.is_empty());
399
400        Ok(())
401    }
402
403    #[test]
404    fn test_go_is_noop() -> anyhow::Result<()> {
405        let tmp = TempDir::new()?;
406        let project = create_test_project(&tmp, ProjectType::Go)?;
407
408        let result = preserve_executables(&project)?;
409        assert!(result.is_empty());
410
411        Ok(())
412    }
413
414    #[test]
415    fn test_preserve_rust_no_profile_dirs() -> anyhow::Result<()> {
416        let tmp = TempDir::new()?;
417        let project = create_test_project(&tmp, ProjectType::Rust)?;
418
419        let result = preserve_executables(&project)?;
420        assert!(result.is_empty());
421        assert!(!tmp.path().join("bin").exists());
422
423        Ok(())
424    }
425
426    // ── Unix-specific tests ─────────────────────────────────────────────
427
428    #[test]
429    #[cfg(unix)]
430    fn test_find_multiple_rust_executables_unix() -> anyhow::Result<()> {
431        use std::os::unix::fs::PermissionsExt;
432
433        let tmp = TempDir::new()?;
434        let project = create_test_project(&tmp, ProjectType::Rust)?;
435
436        let release_dir = tmp.path().join("target/release");
437        fs::create_dir_all(&release_dir)?;
438
439        for name in &["binary-a", "binary-b", "binary-c"] {
440            let exe_path = release_dir.join(name);
441            fs::write(&exe_path, b"fake binary")?;
442            fs::set_permissions(&exe_path, fs::Permissions::from_mode(0o755))?;
443        }
444
445        let result = preserve_executables(&project)?;
446        assert_eq!(result.len(), 3);
447
448        for preserved in &result {
449            assert!(preserved.destination.exists());
450            assert!(
451                preserved
452                    .destination
453                    .starts_with(tmp.path().join("bin/release"))
454            );
455        }
456
457        Ok(())
458    }
459
460    #[test]
461    #[cfg(unix)]
462    fn test_find_rust_executables_excludes_metadata_even_if_executable_unix() -> anyhow::Result<()>
463    {
464        use std::os::unix::fs::PermissionsExt;
465
466        let tmp = TempDir::new()?;
467        let project = create_test_project(&tmp, ProjectType::Rust)?;
468
469        let release_dir = tmp.path().join("target/release");
470        fs::create_dir_all(&release_dir)?;
471
472        let excluded_files = [
473            "dep.d",
474            "lib.rmeta",
475            "lib.rlib",
476            "archive.a",
477            "shared.so",
478            "shared.dylib",
479            "shared.dll",
480            "debug.pdb",
481        ];
482
483        for name in &excluded_files {
484            let file_path = release_dir.join(name);
485            fs::write(&file_path, b"fake content")?;
486            fs::set_permissions(&file_path, fs::Permissions::from_mode(0o755))?;
487        }
488
489        let exe_path = release_dir.join("real-binary");
490        fs::write(&exe_path, b"real binary")?;
491        fs::set_permissions(&exe_path, fs::Permissions::from_mode(0o755))?;
492
493        let result = preserve_executables(&project)?;
494        assert_eq!(result.len(), 1);
495        assert!(
496            result[0]
497                .destination
498                .file_name()
499                .ok_or_else(|| anyhow::anyhow!("missing file name"))?
500                .to_str()
501                .ok_or_else(|| anyhow::anyhow!("non-UTF-8 file name"))?
502                .contains("real-binary")
503        );
504
505        Ok(())
506    }
507
508    #[test]
509    #[cfg(unix)]
510    fn test_is_executable_permission_variants_unix() -> anyhow::Result<()> {
511        use std::os::unix::fs::PermissionsExt;
512
513        let tmp = TempDir::new()?;
514
515        let user_exe = tmp.path().join("user_exe");
516        fs::write(&user_exe, b"content")?;
517        fs::set_permissions(&user_exe, fs::Permissions::from_mode(0o700))?;
518        let meta = user_exe.metadata()?;
519        assert!(is_executable(&user_exe, &meta));
520
521        let group_exe = tmp.path().join("group_exe");
522        fs::write(&group_exe, b"content")?;
523        fs::set_permissions(&group_exe, fs::Permissions::from_mode(0o070))?;
524        let meta = group_exe.metadata()?;
525        assert!(is_executable(&group_exe, &meta));
526
527        let other_exe = tmp.path().join("other_exe");
528        fs::write(&other_exe, b"content")?;
529        fs::set_permissions(&other_exe, fs::Permissions::from_mode(0o601))?;
530        let meta = other_exe.metadata()?;
531        assert!(is_executable(&other_exe, &meta));
532
533        let no_exe = tmp.path().join("no_exe");
534        fs::write(&no_exe, b"content")?;
535        fs::set_permissions(&no_exe, fs::Permissions::from_mode(0o644))?;
536        let meta = no_exe.metadata()?;
537        assert!(!is_executable(&no_exe, &meta));
538
539        Ok(())
540    }
541
542    #[test]
543    #[cfg(unix)]
544    fn test_preserve_rust_debug_and_release_unix() -> anyhow::Result<()> {
545        use std::os::unix::fs::PermissionsExt;
546
547        let tmp = TempDir::new()?;
548        let project = create_test_project(&tmp, ProjectType::Rust)?;
549
550        for profile in &["debug", "release"] {
551            let profile_dir = tmp.path().join("target").join(profile);
552            fs::create_dir_all(&profile_dir)?;
553
554            let exe_path = profile_dir.join("my-binary");
555            fs::write(&exe_path, b"fake binary")?;
556            fs::set_permissions(&exe_path, fs::Permissions::from_mode(0o755))?;
557        }
558
559        let result = preserve_executables(&project)?;
560        assert_eq!(result.len(), 2);
561
562        let dest_names: Vec<_> = result
563            .iter()
564            .map(|p| p.destination.to_string_lossy().to_string())
565            .collect();
566
567        assert!(dest_names.iter().any(|d| d.contains("bin/release")));
568        assert!(dest_names.iter().any(|d| d.contains("bin/debug")));
569
570        Ok(())
571    }
572
573    #[test]
574    #[cfg(unix)]
575    fn test_preserve_python_so_extensions_unix() -> anyhow::Result<()> {
576        let tmp = TempDir::new()?;
577        let project = create_test_project(&tmp, ProjectType::Python)?;
578
579        let build_dir = tmp.path().join("build/lib.linux-x86_64-3.9");
580        fs::create_dir_all(&build_dir)?;
581
582        fs::write(
583            build_dir.join("mymodule.cpython-39-x86_64-linux-gnu.so"),
584            b"shared object",
585        )?;
586        fs::write(build_dir.join("another.so"), b"shared object")?;
587
588        let result = preserve_python_executables(&project)?;
589        assert_eq!(result.len(), 2);
590
591        for preserved in &result {
592            assert!(preserved.destination.exists());
593            assert!(preserved.destination.starts_with(tmp.path().join("bin")));
594        }
595
596        Ok(())
597    }
598
599    // ── Windows-specific tests ──────────────────────────────────────────
600
601    #[test]
602    #[cfg(windows)]
603    fn test_is_executable_case_insensitive_exe_windows() -> anyhow::Result<()> {
604        let tmp = TempDir::new()?;
605
606        let exe = tmp.path().join("app.exe");
607        fs::write(&exe, b"content")?;
608        let meta = exe.metadata()?;
609        assert!(is_executable(&exe, &meta));
610
611        let exe_upper = tmp.path().join("app.EXE");
612        fs::write(&exe_upper, b"content")?;
613        let meta = exe_upper.metadata()?;
614        assert!(is_executable(&exe_upper, &meta));
615
616        let exe_mixed = tmp.path().join("app.Exe");
617        fs::write(&exe_mixed, b"content")?;
618        let meta = exe_mixed.metadata()?;
619        assert!(is_executable(&exe_mixed, &meta));
620
621        let not_exe = tmp.path().join("app.txt");
622        fs::write(&not_exe, b"content")?;
623        let meta = not_exe.metadata()?;
624        assert!(!is_executable(&not_exe, &meta));
625
626        let no_ext = tmp.path().join("app");
627        fs::write(&no_ext, b"content")?;
628        let meta = no_ext.metadata()?;
629        assert!(!is_executable(&no_ext, &meta));
630
631        Ok(())
632    }
633
634    #[test]
635    #[cfg(windows)]
636    fn test_preserve_rust_debug_and_release_windows() -> anyhow::Result<()> {
637        let tmp = TempDir::new()?;
638        let project = create_test_project(&tmp, ProjectType::Rust)?;
639
640        for profile in &["debug", "release"] {
641            let profile_dir = tmp.path().join("target").join(profile);
642            fs::create_dir_all(&profile_dir)?;
643
644            let exe_path = profile_dir.join("my-binary.exe");
645            fs::write(&exe_path, b"fake binary")?;
646        }
647
648        let result = preserve_executables(&project)?;
649        assert_eq!(result.len(), 2);
650
651        let dest_names: Vec<_> = result
652            .iter()
653            .map(|p| p.destination.to_string_lossy().to_string())
654            .collect();
655
656        assert!(dest_names.iter().any(|d| d.contains("release")));
657        assert!(dest_names.iter().any(|d| d.contains("debug")));
658
659        Ok(())
660    }
661
662    #[test]
663    #[cfg(windows)]
664    fn test_find_rust_executables_excludes_metadata_windows() -> anyhow::Result<()> {
665        let tmp = TempDir::new()?;
666        let project = create_test_project(&tmp, ProjectType::Rust)?;
667
668        let release_dir = tmp.path().join("target/release");
669        fs::create_dir_all(&release_dir)?;
670
671        fs::write(release_dir.join("dep.d"), b"dep info")?;
672        fs::write(release_dir.join("lib.dll"), b"library")?;
673        fs::write(release_dir.join("debug.pdb"), b"symbols")?;
674        fs::write(release_dir.join("lib.rlib"), b"rust lib")?;
675
676        fs::write(release_dir.join("my-binary.exe"), b"real binary")?;
677
678        let result = preserve_executables(&project)?;
679        assert_eq!(result.len(), 1);
680        assert!(
681            result[0]
682                .destination
683                .file_name()
684                .ok_or_else(|| anyhow::anyhow!("missing file name"))?
685                .to_str()
686                .ok_or_else(|| anyhow::anyhow!("non-UTF-8 file name"))?
687                .contains("my-binary.exe")
688        );
689
690        Ok(())
691    }
692
693    #[test]
694    #[cfg(windows)]
695    fn test_find_multiple_rust_executables_windows() -> anyhow::Result<()> {
696        let tmp = TempDir::new()?;
697        let project = create_test_project(&tmp, ProjectType::Rust)?;
698
699        let release_dir = tmp.path().join("target/release");
700        fs::create_dir_all(&release_dir)?;
701
702        for name in &["binary-a.exe", "binary-b.exe", "binary-c.exe"] {
703            fs::write(release_dir.join(name), b"fake binary")?;
704        }
705
706        let result = preserve_executables(&project)?;
707        assert_eq!(result.len(), 3);
708
709        Ok(())
710    }
711
712    #[test]
713    #[cfg(windows)]
714    fn test_preserve_python_pyd_extensions_windows() -> anyhow::Result<()> {
715        let tmp = TempDir::new()?;
716        let project = create_test_project(&tmp, ProjectType::Python)?;
717
718        let build_dir = tmp.path().join("build/lib.win-amd64-3.9");
719        fs::create_dir_all(&build_dir)?;
720
721        fs::write(
722            build_dir.join("mymodule.cp39-win_amd64.pyd"),
723            b"python extension",
724        )?;
725        fs::write(build_dir.join("another.pyd"), b"python extension")?;
726
727        let result = preserve_python_executables(&project)?;
728        assert_eq!(result.len(), 2);
729
730        for preserved in &result {
731            assert!(preserved.destination.exists());
732        }
733
734        Ok(())
735    }
736
737    // ── Cross-platform tests (run on all OS) ────────────────────────────
738
739    #[test]
740    fn test_preserve_python_whl_files() -> anyhow::Result<()> {
741        let tmp = TempDir::new()?;
742        let project = create_test_project(&tmp, ProjectType::Python)?;
743
744        let dist_dir = tmp.path().join("dist");
745        fs::create_dir_all(&dist_dir)?;
746
747        fs::write(
748            dist_dir.join("mypackage-1.0.0-py3-none-any.whl"),
749            b"wheel content",
750        )?;
751        fs::write(dist_dir.join("mypackage-1.0.0.tar.gz"), b"tarball content")?;
752
753        let result = preserve_python_executables(&project)?;
754        assert_eq!(result.len(), 1);
755        assert!(
756            result[0]
757                .destination
758                .extension()
759                .is_some_and(|ext| ext.eq_ignore_ascii_case("whl"))
760        );
761
762        Ok(())
763    }
764
765    #[test]
766    fn test_preserve_python_no_dist_no_build() -> anyhow::Result<()> {
767        let tmp = TempDir::new()?;
768        let project = create_test_project(&tmp, ProjectType::Python)?;
769
770        let result = preserve_python_executables(&project)?;
771        assert!(result.is_empty());
772
773        Ok(())
774    }
775
776    #[test]
777    fn test_preserve_python_empty_dist_and_build() -> anyhow::Result<()> {
778        let tmp = TempDir::new()?;
779        let project = create_test_project(&tmp, ProjectType::Python)?;
780
781        fs::create_dir_all(tmp.path().join("dist"))?;
782        fs::create_dir_all(tmp.path().join("build"))?;
783
784        let result = preserve_python_executables(&project)?;
785        assert!(result.is_empty());
786
787        Ok(())
788    }
789
790    #[test]
791    fn test_preserve_python_whl_and_extensions_combined() -> anyhow::Result<()> {
792        let tmp = TempDir::new()?;
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)?;
797        fs::write(dist_dir.join("mypackage-1.0.0-py3-none-any.whl"), b"wheel")?;
798
799        let build_dir = tmp.path().join("build/lib");
800        fs::create_dir_all(&build_dir)?;
801
802        #[cfg(unix)]
803        fs::write(build_dir.join("native.so"), b"shared object")?;
804
805        #[cfg(windows)]
806        fs::write(build_dir.join("native.pyd"), b"python extension")?;
807
808        let result = preserve_python_executables(&project)?;
809        assert_eq!(result.len(), 2);
810
811        Ok(())
812    }
813
814    #[test]
815    fn test_preserve_executables_returns_correct_source_paths() -> anyhow::Result<()> {
816        let tmp = TempDir::new()?;
817        let project = create_test_project(&tmp, ProjectType::Python)?;
818
819        let dist_dir = tmp.path().join("dist");
820        fs::create_dir_all(&dist_dir)?;
821        let whl_path = dist_dir.join("pkg-1.0-py3-none-any.whl");
822        fs::write(&whl_path, b"wheel content")?;
823
824        let result = preserve_python_executables(&project)?;
825        assert_eq!(result.len(), 1);
826        assert_eq!(result[0].source, whl_path);
827        assert_eq!(
828            result[0].destination,
829            tmp.path().join("bin/pkg-1.0-py3-none-any.whl")
830        );
831
832        Ok(())
833    }
834}