1use std::fs;
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result};
11
12use crate::project::{Project, ProjectType};
13
14const RUST_EXCLUDED_EXTENSIONS: &[&str] = &["d", "rmeta", "rlib", "a", "so", "dylib", "dll", "pdb"];
16
17#[cfg(unix)]
22fn is_executable(path: &Path, metadata: &fs::Metadata) -> bool {
23 use std::os::unix::fs::PermissionsExt;
24
25 let _ = path; 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#[derive(Debug)]
38pub struct PreservedExecutable {
39 pub source: PathBuf,
41 pub destination: PathBuf,
43}
44
45pub 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
78fn 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
127fn 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 if let Some(ext) = path.extension().and_then(|e| e.to_str())
148 && RUST_EXCLUDED_EXTENSIONS.contains(&ext)
149 {
150 continue;
151 }
152
153 let metadata = path.metadata()?;
155 if is_executable(&path, &metadata) {
156 executables.push(path);
157 }
158 }
159
160 Ok(executables)
161}
162
163fn 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
175fn 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
199fn 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
231fn 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 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 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 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 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 let result = preserve_executables(&project).unwrap();
413 assert!(result.is_empty());
414 assert!(!tmp.path().join("bin").exists());
415 }
416
417 #[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 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 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 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 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 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 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 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 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 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 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 #[test]
593 #[cfg(windows)]
594 fn test_is_executable_case_insensitive_exe_windows() {
595 let tmp = TempDir::new().unwrap();
596
597 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 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 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 let not_exe = tmp.path().join("app.txt");
617 fs::write(¬_exe, b"content").unwrap();
618 let meta = not_exe.metadata().unwrap();
619 assert!(!is_executable(¬_exe, &meta));
620
621 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 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 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 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 #[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 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 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 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 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 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 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 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}