Skip to main content

cuenv_release/
artifact.rs

1//! Artifact generation for release binaries.
2//!
3//! This module handles:
4//! - Target platform enumeration
5//! - Tarball creation with gzip compression
6//! - SHA256 checksum generation
7//! - Checksums manifest file creation
8
9use crate::error::{Error, Result};
10use flate2::Compression;
11use flate2::write::GzEncoder;
12use sha2::{Digest, Sha256};
13use std::collections::HashMap;
14use std::fmt;
15use std::fs::File;
16use std::io::{BufReader, Read};
17use std::path::{Path, PathBuf};
18use std::str::FromStr;
19
20/// Supported build targets for binary distribution.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum Target {
23    /// Linux `x86_64`
24    LinuxX64,
25    /// Linux ARM64/aarch64
26    LinuxArm64,
27    /// macOS ARM64 (Apple Silicon)
28    DarwinArm64,
29}
30
31impl Target {
32    /// Returns the Rust target triple for this target.
33    #[must_use]
34    pub const fn rust_triple(&self) -> &'static str {
35        match self {
36            Self::LinuxX64 => "x86_64-unknown-linux-gnu",
37            Self::LinuxArm64 => "aarch64-unknown-linux-gnu",
38            Self::DarwinArm64 => "aarch64-apple-darwin",
39        }
40    }
41
42    /// Returns the OS string for archive naming.
43    #[must_use]
44    pub const fn os(&self) -> &'static str {
45        match self {
46            Self::LinuxX64 | Self::LinuxArm64 => "linux",
47            Self::DarwinArm64 => "darwin",
48        }
49    }
50
51    /// Returns the architecture string for archive naming.
52    #[must_use]
53    pub const fn arch(&self) -> &'static str {
54        match self {
55            Self::LinuxX64 => "x86_64",
56            Self::LinuxArm64 | Self::DarwinArm64 => "arm64",
57        }
58    }
59
60    /// Returns the short identifier (e.g., "linux-x64").
61    #[must_use]
62    pub const fn short_id(&self) -> &'static str {
63        match self {
64            Self::LinuxX64 => "linux-x64",
65            Self::LinuxArm64 => "linux-arm64",
66            Self::DarwinArm64 => "darwin-arm64",
67        }
68    }
69
70    /// Returns the GitHub Actions runner for this target.
71    #[must_use]
72    pub const fn github_runner(&self) -> &'static str {
73        match self {
74            Self::LinuxX64 | Self::LinuxArm64 => "ubuntu-latest",
75            Self::DarwinArm64 => "macos-14",
76        }
77    }
78
79    /// Returns all supported targets.
80    #[must_use]
81    pub const fn all() -> &'static [Self] {
82        &[Self::LinuxX64, Self::LinuxArm64, Self::DarwinArm64]
83    }
84
85    /// Parses a target from a Rust triple.
86    #[must_use]
87    pub fn from_rust_triple(triple: &str) -> Option<Self> {
88        match triple {
89            "x86_64-unknown-linux-gnu" => Some(Self::LinuxX64),
90            "aarch64-unknown-linux-gnu" => Some(Self::LinuxArm64),
91            "aarch64-apple-darwin" => Some(Self::DarwinArm64),
92            _ => None,
93        }
94    }
95}
96
97impl fmt::Display for Target {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        write!(f, "{}", self.short_id())
100    }
101}
102
103impl FromStr for Target {
104    type Err = Error;
105
106    fn from_str(s: &str) -> Result<Self> {
107        match s {
108            "linux-x64" => Ok(Self::LinuxX64),
109            "linux-arm64" => Ok(Self::LinuxArm64),
110            "darwin-arm64" => Ok(Self::DarwinArm64),
111            _ => Err(Error::artifact(
112                format!("Unknown target: {s}. Valid targets: linux-x64, linux-arm64, darwin-arm64"),
113                None,
114            )),
115        }
116    }
117}
118
119/// A built binary artifact ready for packaging.
120#[derive(Debug, Clone)]
121pub struct Artifact {
122    /// The target platform this artifact was built for.
123    pub target: Target,
124    /// Path to the compiled binary.
125    pub binary_path: PathBuf,
126    /// Name of the binary (e.g., "cuenv").
127    pub name: String,
128}
129
130/// A packaged release artifact (tarball + checksum).
131#[derive(Debug, Clone)]
132pub struct PackagedArtifact {
133    /// The target platform.
134    pub target: Target,
135    /// Path to the .tar.gz archive.
136    pub archive_path: PathBuf,
137    /// Path to the .sha256 checksum file.
138    pub checksum_path: PathBuf,
139    /// Name of the archive file.
140    pub archive_name: String,
141    /// SHA256 checksum hex string.
142    pub sha256: String,
143}
144
145/// Builder for creating release artifacts.
146pub struct ArtifactBuilder {
147    output_dir: PathBuf,
148    version: String,
149    binary_name: String,
150}
151
152impl ArtifactBuilder {
153    /// Creates a new artifact builder.
154    ///
155    /// # Arguments
156    /// * `output_dir` - Directory to write archives to
157    /// * `version` - Version string (e.g., "0.16.0")
158    /// * `binary_name` - Name of the binary (e.g., "cuenv")
159    #[must_use]
160    pub fn new(
161        output_dir: impl Into<PathBuf>,
162        version: impl Into<String>,
163        binary_name: impl Into<String>,
164    ) -> Self {
165        Self {
166            output_dir: output_dir.into(),
167            version: version.into(),
168            binary_name: binary_name.into(),
169        }
170    }
171
172    /// Generates the archive filename for a target.
173    ///
174    /// Format: `{binary}-{version}-{os}-{arch}.tar.gz`
175    #[must_use]
176    pub fn archive_name(&self, target: Target) -> String {
177        format!(
178            "{}-{}-{}-{}.tar.gz",
179            self.binary_name,
180            self.version,
181            target.os(),
182            target.arch()
183        )
184    }
185
186    /// Packages an artifact into a tarball with checksum.
187    ///
188    /// Creates:
189    /// - `{binary}-{version}-{os}-{arch}.tar.gz`
190    /// - `{binary}-{version}-{os}-{arch}.tar.gz.sha256`
191    ///
192    /// # Errors
193    ///
194    /// Returns an error if:
195    /// - Output directory cannot be created
196    /// - Tarball creation fails
197    /// - Checksum computation fails
198    pub fn package(&self, artifact: &Artifact) -> Result<PackagedArtifact> {
199        // Ensure output directory exists
200        std::fs::create_dir_all(&self.output_dir)?;
201
202        let archive_name = self.archive_name(artifact.target);
203        let archive_path = self.output_dir.join(&archive_name);
204        let checksum_path = self.output_dir.join(format!("{archive_name}.sha256"));
205
206        // Create the tarball
207        self.create_tarball(artifact, &archive_path)?;
208
209        // Compute SHA256
210        let sha256 = Self::compute_sha256(&archive_path)?;
211
212        // Write checksum file
213        self.write_checksum_file(&checksum_path, &sha256, &archive_name)?;
214
215        Ok(PackagedArtifact {
216            target: artifact.target,
217            archive_path,
218            checksum_path,
219            archive_name,
220            sha256,
221        })
222    }
223
224    /// Packages multiple artifacts.
225    ///
226    /// # Errors
227    ///
228    /// Returns an error if any artifact fails to package.
229    pub fn package_all(&self, artifacts: &[Artifact]) -> Result<Vec<PackagedArtifact>> {
230        artifacts.iter().map(|a| self.package(a)).collect()
231    }
232
233    /// Computes the SHA256 checksum of a file.
234    ///
235    /// # Errors
236    ///
237    /// Returns an error if the file cannot be opened or read.
238    pub fn compute_sha256(path: &Path) -> Result<String> {
239        let file = File::open(path).map_err(|e| {
240            Error::artifact(
241                format!("Failed to open file for checksum: {e}"),
242                Some(path.to_path_buf()),
243            )
244        })?;
245        let mut reader = BufReader::new(file);
246        let mut hasher = Sha256::new();
247        let mut buffer = [0u8; 8192];
248
249        loop {
250            let bytes_read = reader.read(&mut buffer).map_err(|e| {
251                Error::artifact(
252                    format!("Failed to read file for checksum: {e}"),
253                    Some(path.to_path_buf()),
254                )
255            })?;
256            if bytes_read == 0 {
257                break;
258            }
259            hasher.update(&buffer[..bytes_read]);
260        }
261
262        let hash = hasher.finalize();
263        Ok(format!("{hash:x}"))
264    }
265
266    /// Creates a tarball containing the binary.
267    #[allow(clippy::unused_self)]
268    fn create_tarball(&self, artifact: &Artifact, output: &Path) -> Result<()> {
269        let file = File::create(output).map_err(|e| {
270            Error::artifact(
271                format!("Failed to create archive: {e}"),
272                Some(output.to_path_buf()),
273            )
274        })?;
275        let encoder = GzEncoder::new(file, Compression::default());
276        let mut archive = tar::Builder::new(encoder);
277
278        // Read the binary
279        let binary_file = File::open(&artifact.binary_path).map_err(|e| {
280            Error::artifact(
281                format!("Failed to open binary: {e}"),
282                Some(artifact.binary_path.clone()),
283            )
284        })?;
285        let metadata = binary_file.metadata().map_err(|e| {
286            Error::artifact(
287                format!("Failed to read binary metadata: {e}"),
288                Some(artifact.binary_path.clone()),
289            )
290        })?;
291
292        // Create tar header
293        let mut header = tar::Header::new_gnu();
294        header.set_path(&artifact.name)?;
295        header.set_size(metadata.len());
296        header.set_mode(0o755); // Executable
297        header.set_cksum();
298
299        // Add to archive
300        archive.append(&header, &binary_file)?;
301        archive.finish()?;
302
303        Ok(())
304    }
305
306    /// Writes a checksum file in the standard format.
307    #[allow(clippy::unused_self)]
308    fn write_checksum_file(&self, path: &Path, sha256: &str, filename: &str) -> Result<()> {
309        let content = format!("{sha256}  {filename}\n");
310        std::fs::write(path, content)?;
311        Ok(())
312    }
313}
314
315/// Checksums manifest containing all artifact checksums.
316#[derive(Debug, Default)]
317pub struct ChecksumsManifest {
318    /// Map of filename to SHA256 checksum.
319    entries: HashMap<String, String>,
320}
321
322impl ChecksumsManifest {
323    /// Creates a new empty manifest.
324    #[must_use]
325    pub fn new() -> Self {
326        Self::default()
327    }
328
329    /// Adds a checksum entry.
330    pub fn add(&mut self, filename: impl Into<String>, sha256: impl Into<String>) {
331        self.entries.insert(filename.into(), sha256.into());
332    }
333
334    /// Creates a manifest from packaged artifacts.
335    #[must_use]
336    pub fn from_artifacts(artifacts: &[PackagedArtifact]) -> Self {
337        let mut manifest = Self::new();
338        for artifact in artifacts {
339            manifest.add(artifact.archive_name.clone(), artifact.sha256.clone());
340        }
341        manifest
342    }
343
344    /// Returns the checksums in the standard format.
345    ///
346    /// Format: `{sha256}  {filename}\n` (note: two spaces, matching sha256sum output)
347    #[must_use]
348    pub fn to_checksums_format(&self) -> String {
349        let mut lines: Vec<_> = self
350            .entries
351            .iter()
352            .map(|(filename, sha256)| format!("{sha256}  {filename}"))
353            .collect();
354        lines.sort(); // Deterministic ordering
355        lines.join("\n") + "\n"
356    }
357
358    /// Writes the manifest to a CHECKSUMS.txt file.
359    ///
360    /// # Errors
361    ///
362    /// Returns an error if writing to the file fails.
363    pub fn write(&self, path: &Path) -> Result<()> {
364        let content = self.to_checksums_format();
365        std::fs::write(path, content)?;
366        Ok(())
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use tempfile::TempDir;
374
375    #[test]
376    fn test_target_rust_triple() {
377        assert_eq!(Target::LinuxX64.rust_triple(), "x86_64-unknown-linux-gnu");
378        assert_eq!(
379            Target::LinuxArm64.rust_triple(),
380            "aarch64-unknown-linux-gnu"
381        );
382        assert_eq!(Target::DarwinArm64.rust_triple(), "aarch64-apple-darwin");
383    }
384
385    #[test]
386    fn test_target_short_id() {
387        assert_eq!(Target::LinuxX64.short_id(), "linux-x64");
388        assert_eq!(Target::LinuxArm64.short_id(), "linux-arm64");
389        assert_eq!(Target::DarwinArm64.short_id(), "darwin-arm64");
390    }
391
392    #[test]
393    fn test_target_from_str() {
394        assert_eq!(Target::from_str("linux-x64").unwrap(), Target::LinuxX64);
395        assert_eq!(
396            Target::from_str("darwin-arm64").unwrap(),
397            Target::DarwinArm64
398        );
399        assert!(Target::from_str("unknown").is_err());
400    }
401
402    #[test]
403    fn test_target_from_rust_triple() {
404        assert_eq!(
405            Target::from_rust_triple("x86_64-unknown-linux-gnu"),
406            Some(Target::LinuxX64)
407        );
408        assert_eq!(Target::from_rust_triple("unknown-triple"), None);
409    }
410
411    #[test]
412    fn test_archive_name() {
413        let builder = ArtifactBuilder::new("/tmp", "0.16.0", "cuenv");
414        assert_eq!(
415            builder.archive_name(Target::LinuxX64),
416            "cuenv-0.16.0-linux-x86_64.tar.gz"
417        );
418        assert_eq!(
419            builder.archive_name(Target::DarwinArm64),
420            "cuenv-0.16.0-darwin-arm64.tar.gz"
421        );
422    }
423
424    #[test]
425    fn test_compute_sha256() {
426        let temp = TempDir::new().unwrap();
427        let file_path = temp.path().join("test.txt");
428        std::fs::write(&file_path, "hello world").unwrap();
429
430        let hash = ArtifactBuilder::compute_sha256(&file_path).unwrap();
431        assert_eq!(hash.len(), 64); // SHA256 hex length
432
433        // Known hash for "hello world"
434        assert_eq!(
435            hash,
436            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
437        );
438    }
439
440    #[test]
441    fn test_package_artifact() {
442        let temp = TempDir::new().unwrap();
443        let binary_path = temp.path().join("cuenv");
444        std::fs::write(&binary_path, "#!/bin/bash\necho hello").unwrap();
445
446        let output_dir = temp.path().join("dist");
447        let builder = ArtifactBuilder::new(&output_dir, "0.16.0", "cuenv");
448
449        let artifact = Artifact {
450            target: Target::LinuxX64,
451            binary_path,
452            name: "cuenv".to_string(),
453        };
454
455        let packaged = builder.package(&artifact).unwrap();
456
457        assert!(packaged.archive_path.exists());
458        assert!(packaged.checksum_path.exists());
459        assert_eq!(packaged.archive_name, "cuenv-0.16.0-linux-x86_64.tar.gz");
460        assert_eq!(packaged.sha256.len(), 64);
461    }
462
463    #[test]
464    fn test_checksums_manifest() {
465        let mut manifest = ChecksumsManifest::new();
466        manifest.add("file1.tar.gz", "abc123");
467        manifest.add("file2.tar.gz", "def456");
468
469        let output = manifest.to_checksums_format();
470        assert!(output.contains("abc123  file1.tar.gz"));
471        assert!(output.contains("def456  file2.tar.gz"));
472    }
473}