Skip to main content

oxide_cli/utils/
archive.rs

1use std::{io::Cursor, path::Path};
2
3use anyhow::Result;
4use flate2::read::GzDecoder;
5use reqwest::Client;
6use tar::Archive;
7
8/// Download a GitHub tarball directly and extract it to `dest`.
9///
10/// `subdir` — optional path within the repo to extract (e.g. `"templates/react"`).
11/// Pass `None` to extract the full repo root.
12pub async fn download_and_extract(
13  client: &Client,
14  archive_url: &str,
15  dest: &Path,
16  subdir: Option<&str>,
17) -> Result<()> {
18  let bytes = client
19    .get(archive_url)
20    .header("User-Agent", "oxide")
21    .send()
22    .await?
23    .error_for_status()?
24    .bytes()
25    .await?;
26
27  std::fs::create_dir_all(dest)?;
28
29  let gz = GzDecoder::new(Cursor::new(bytes));
30  let mut archive = Archive::new(gz);
31
32  for entry in archive.entries()? {
33    let mut entry = entry?;
34    let raw_path = entry.path()?.into_owned();
35
36    // GitHub tarballs always have a single root dir: {owner}-{repo}-{short_sha}/
37    // Strip it so all paths are relative to the repo root.
38    let mut components = raw_path.components();
39    components.next(); // discard the archive root component
40    let stripped = components.as_path();
41
42    // If the template lives in a subdirectory, skip everything outside it
43    // and strip that prefix so files land directly in `dest`.
44    let rel = if let Some(dir) = subdir {
45      match stripped.strip_prefix(dir) {
46        Ok(r) => r.to_owned(),
47        Err(_) => continue,
48      }
49    } else {
50      stripped.to_owned()
51    };
52
53    if rel.as_os_str().is_empty() {
54      continue; // the directory entry itself — nothing to write
55    }
56
57    let out_path = dest.join(&rel);
58    if let Some(parent) = out_path.parent() {
59      std::fs::create_dir_all(parent)?;
60    }
61    entry.unpack(&out_path)?;
62  }
63
64  Ok(())
65}