1use std::{
2 env, fs, io,
3 path::{Path, PathBuf},
4 process::{Command, Output},
5 time::{SystemTime, UNIX_EPOCH},
6};
7use thiserror::Error;
8
9const SKIP_PATTERNS: &[&str] = &[
10 "target",
11 ".git",
12 ".hg",
13 ".svn",
14 ".idea",
15 ".vscode",
16 "node_modules",
17 "__pycache__",
18 ".DS_Store",
19];
20
21#[derive(Debug, Error)]
22pub enum ArchiveError {
23 #[error("package root {0} does not exist")]
24 RootMissing(PathBuf),
25
26 #[error("failed to spawn tar command: {0}")]
27 Spawn(#[from] io::Error),
28
29 #[error("tar command failed with status {status}: {stderr}")]
30 CommandFailed { status: i32, stderr: String },
31}
32
33#[derive(Debug)]
34pub struct PackageArchive {
35 path: PathBuf,
36}
37
38impl PackageArchive {
39 pub fn path(&self) -> &Path {
40 &self.path
41 }
42
43 pub fn into_path(self) -> PathBuf {
44 let path = self.path.clone();
45 std::mem::forget(self);
46 path
47 }
48}
49
50impl Drop for PackageArchive {
51 fn drop(&mut self) {
52 let _ = fs::remove_file(&self.path);
53 }
54}
55
56pub fn build_package_archive(root: &Path) -> Result<PackageArchive, ArchiveError> {
57 if !root.exists() {
58 return Err(ArchiveError::RootMissing(root.to_path_buf()));
59 }
60 let output_path = temp_archive_path();
61 let mut command = Command::new(resolve_tar_command());
62 command.arg("-czf");
63 command.arg(&output_path);
64 for pattern in SKIP_PATTERNS {
65 command.arg(format!("--exclude={pattern}"));
66 }
67 command.arg("-C");
68 command.arg(root);
69 command.arg(".");
70 let output = command.output()?;
71 ensure_success(output)?;
72 Ok(PackageArchive { path: output_path })
73}
74
75fn resolve_tar_command() -> &'static str {
76 #[cfg(target_os = "windows")]
77 {
78 "tar.exe"
79 }
80 #[cfg(not(target_os = "windows"))]
81 {
82 "tar"
83 }
84}
85
86fn temp_archive_path() -> PathBuf {
87 let mut path = env::temp_dir();
88 let timestamp = SystemTime::now()
89 .duration_since(UNIX_EPOCH)
90 .unwrap_or_default()
91 .as_micros();
92 let pid = std::process::id();
93 path.push(format!("lust-package-{pid}-{timestamp}.tar.gz"));
94 path
95}
96
97fn ensure_success(output: Output) -> Result<(), ArchiveError> {
98 if output.status.success() {
99 Ok(())
100 } else {
101 let code = output.status.code().unwrap_or(-1);
102 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
103 Err(ArchiveError::CommandFailed {
104 status: code,
105 stderr,
106 })
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113 use std::fs;
114 use std::process::Command;
115 use tempfile::tempdir;
116
117 fn list_archive_contents(path: &Path) -> Vec<String> {
118 let output = Command::new(resolve_tar_command())
119 .arg("-tzf")
120 .arg(path)
121 .output()
122 .expect("tar -tzf");
123 assert!(output.status.success(), "tar -tzf failed");
124 String::from_utf8_lossy(&output.stdout)
125 .lines()
126 .map(|line| {
127 let trimmed = line.trim();
128 trimmed.strip_prefix("./").unwrap_or(trimmed).to_string()
129 })
130 .collect()
131 }
132
133 #[test]
134 fn archive_skips_target_directory() {
135 let dir = tempdir().unwrap();
136 let root = dir.path();
137 fs::create_dir_all(root.join("target/cache")).unwrap();
138 fs::create_dir_all(root.join("src")).unwrap();
139 fs::write(root.join("src/lib.lust"), "content").unwrap();
140 fs::write(root.join("target/cache.bin"), "ignore").unwrap();
141
142 let archive = build_package_archive(root).unwrap();
143 let entries = list_archive_contents(archive.path());
144 assert!(entries.iter().any(|entry| entry == "src/lib.lust"));
145 assert!(!entries.iter().any(|entry| entry.starts_with("target/")));
146 }
147}