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 => Ok(Vec::new()),
67 }
68}
69
70fn 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
116fn 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 if let Some(ext) = path.extension().and_then(|e| e.to_str())
137 && RUST_EXCLUDED_EXTENSIONS.contains(&ext)
138 {
139 continue;
140 }
141
142 let metadata = path.metadata()?;
144 if is_executable(&path, &metadata) {
145 executables.push(path);
146 }
147 }
148
149 Ok(executables)
150}
151
152fn 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
164fn 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
188fn 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
220fn 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 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 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 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 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 let result = preserve_executables(&project).unwrap();
399 assert!(result.is_empty());
400 assert!(!tmp.path().join("bin").exists());
401 }
402
403 #[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 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 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 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 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 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 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 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 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 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 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 #[test]
579 #[cfg(windows)]
580 fn test_is_executable_case_insensitive_exe_windows() {
581 let tmp = TempDir::new().unwrap();
582
583 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 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 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 let not_exe = tmp.path().join("app.txt");
603 fs::write(¬_exe, b"content").unwrap();
604 let meta = not_exe.metadata().unwrap();
605 assert!(!is_executable(¬_exe, &meta));
606
607 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 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 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 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 #[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 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 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 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 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 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 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 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}