1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum Target {
23 LinuxX64,
25 LinuxArm64,
27 DarwinArm64,
29}
30
31impl Target {
32 #[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 #[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 #[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 #[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 #[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 #[must_use]
81 pub const fn all() -> &'static [Self] {
82 &[Self::LinuxX64, Self::LinuxArm64, Self::DarwinArm64]
83 }
84
85 #[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#[derive(Debug, Clone)]
121pub struct Artifact {
122 pub target: Target,
124 pub binary_path: PathBuf,
126 pub name: String,
128}
129
130#[derive(Debug, Clone)]
132pub struct PackagedArtifact {
133 pub target: Target,
135 pub archive_path: PathBuf,
137 pub checksum_path: PathBuf,
139 pub archive_name: String,
141 pub sha256: String,
143}
144
145pub struct ArtifactBuilder {
147 output_dir: PathBuf,
148 version: String,
149 binary_name: String,
150}
151
152impl ArtifactBuilder {
153 #[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 #[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 pub fn package(&self, artifact: &Artifact) -> Result<PackagedArtifact> {
199 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 self.create_tarball(artifact, &archive_path)?;
208
209 let sha256 = Self::compute_sha256(&archive_path)?;
211
212 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 pub fn package_all(&self, artifacts: &[Artifact]) -> Result<Vec<PackagedArtifact>> {
230 artifacts.iter().map(|a| self.package(a)).collect()
231 }
232
233 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 #[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 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 let mut header = tar::Header::new_gnu();
294 header.set_path(&artifact.name)?;
295 header.set_size(metadata.len());
296 header.set_mode(0o755); header.set_cksum();
298
299 archive.append(&header, &binary_file)?;
301 archive.finish()?;
302
303 Ok(())
304 }
305
306 #[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#[derive(Debug, Default)]
317pub struct ChecksumsManifest {
318 entries: HashMap<String, String>,
320}
321
322impl ChecksumsManifest {
323 #[must_use]
325 pub fn new() -> Self {
326 Self::default()
327 }
328
329 pub fn add(&mut self, filename: impl Into<String>, sha256: impl Into<String>) {
331 self.entries.insert(filename.into(), sha256.into());
332 }
333
334 #[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 #[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(); lines.join("\n") + "\n"
356 }
357
358 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); 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}