lust/packages/
archive.rs

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}