bckt 0.6.2

bckt is an opinionated but flexible static site generator for blogs
use std::fs::{self, File};
use std::io::{self, Read, Seek, Write};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, anyhow};
use tempfile::NamedTempFile;
use ureq::Response;
use zip::ZipArchive;

#[derive(Debug, Clone)]
pub enum GithubReference {
    Tag(String),
    Branch(String),
}

#[derive(Debug, Clone)]
pub enum ThemeSource {
    Github {
        owner: String,
        repo: String,
        reference: GithubReference,
        subdir: Option<String>,
        strip_components: Option<usize>,
    },
    Url {
        url: String,
        subdir: Option<String>,
        strip_components: Option<usize>,
    },
}

pub fn download_theme(destination: &Path, source: ThemeSource) -> Result<()> {
    if destination.exists() {
        fs::remove_dir_all(destination).with_context(|| {
            format!(
                "failed to remove existing directory {}",
                destination.display()
            )
        })?;
    }
    fs::create_dir_all(destination)
        .with_context(|| format!("failed to create directory {}", destination.display()))?;

    let mut temp =
        NamedTempFile::new().context("failed to create temporary file for theme download")?;

    let (archive_url, subdir, strip_components) = match &source {
        ThemeSource::Github {
            owner,
            repo,
            reference,
            subdir,
            strip_components,
        } => {
            let (url, default_strip) = match reference {
                GithubReference::Tag(tag) => (
                    format!(
                        "https://codeload.github.com/{owner}/{repo}/zip/refs/tags/{tag}",
                        owner = owner,
                        repo = repo,
                        tag = tag
                    ),
                    1usize,
                ),
                GithubReference::Branch(branch) => (
                    format!(
                        "https://codeload.github.com/{owner}/{repo}/zip/refs/heads/{branch}",
                        owner = owner,
                        repo = repo,
                        branch = branch
                    ),
                    1usize,
                ),
            };
            (
                url,
                subdir.clone(),
                strip_components.unwrap_or(default_strip),
            )
        }
        ThemeSource::Url {
            url,
            subdir,
            strip_components,
        } => (
            url.clone(),
            subdir.clone(),
            strip_components.unwrap_or(0usize),
        ),
    };

    download_to_file(&archive_url, temp.as_file_mut())?;

    let file = File::open(temp.path()).context("failed to reopen downloaded theme archive")?;
    let mut archive = ZipArchive::new(file).context("failed to read theme archive")?;

    extract_theme_archive(
        &mut archive,
        destination,
        strip_components,
        subdir.as_deref(),
    )
}

fn download_to_file(url: &str, mut target: &mut File) -> Result<()> {
    let response = ureq::get(url)
        .set(
            "User-Agent",
            concat!(
                "bckt/",
                env!("CARGO_PKG_VERSION"),
                " (https://github.com/vrypan/bckt)"
            ),
        )
        .set("Accept", "application/octet-stream")
        .call();
    let response: Response = match response {
        Ok(resp) => resp,
        Err(ureq::Error::Status(code, resp)) => {
            let status_text = resp.status_text().to_string();
            return Err(anyhow!(
                "download request failed with status {code} ({status_text}) for {url}"
            ));
        }
        Err(err) => return Err(anyhow!("failed to download {url}: {err}")),
    };

    let mut reader = response.into_reader();
    io::copy(&mut reader, &mut target)
        .with_context(|| format!("failed to write downloaded archive from {url}"))?;
    target
        .flush()
        .context("failed to flush downloaded archive to temporary file")?;
    Ok(())
}

fn extract_theme_archive<R: Read + Seek>(
    archive: &mut ZipArchive<R>,
    destination: &Path,
    strip_components: usize,
    subdir: Option<&str>,
) -> Result<()> {
    let mut extracted_any = false;
    let subdir_path = subdir.map(PathBuf::from);

    for i in 0..archive.len() {
        let mut entry = archive
            .by_index(i)
            .with_context(|| format!("failed to read archive entry #{i}"))?;
        let name = entry.name().to_string();
        if entry.is_dir() {
            continue;
        }

        let relative = match compute_relative_path(&name, strip_components, subdir_path.as_deref())
        {
            Some(rel) if !rel.as_os_str().is_empty() => rel,
            _ => continue,
        };

        let out_path = destination.join(&relative);
        if let Some(parent) = out_path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("failed to create directory {}", parent.display()))?;
        }

        let mut outfile = File::create(&out_path)
            .with_context(|| format!("failed to create file {}", out_path.display()))?;
        io::copy(&mut entry, &mut outfile)
            .with_context(|| format!("failed to write {}", out_path.display()))?;

        #[cfg(unix)]
        if let Some(mode) = entry.unix_mode() {
            use std::os::unix::fs::PermissionsExt;
            fs::set_permissions(&out_path, fs::Permissions::from_mode(mode))
                .with_context(|| format!("failed to set permissions on {}", out_path.display()))?;
        }

        extracted_any = true;
    }

    if !extracted_any {
        return Err(anyhow!("no files extracted from archive"));
    }

    Ok(())
}

fn compute_relative_path(
    entry_name: &str,
    strip_components: usize,
    subdir: Option<&Path>,
) -> Option<PathBuf> {
    let entry_path = Path::new(entry_name);
    let total_components = entry_path.iter().count();

    if total_components <= strip_components {
        return None;
    }

    let mut stripped = PathBuf::new();
    for component in entry_path.iter().skip(strip_components) {
        stripped.push(component);
    }

    if let Some(prefix) = subdir {
        if !stripped.starts_with(prefix) {
            return None;
        }
        return stripped.strip_prefix(prefix).ok().map(|p| p.to_path_buf());
    }

    Some(stripped)
}