zoi-rs 1.7.0

Universal Package Manager & Environment Setup Tool
Documentation
use crate::{pkg, utils};
use anyhow::{Result, anyhow};
use colored::*;
use mlua::{Lua, LuaSerdeExt, Table};
use std::fs::{self, File};
use std::path::Path;
use tar::Builder as TarBuilder;
use tempfile::Builder;
use walkdir::WalkDir;
use zstd::stream::write::Encoder as ZstdEncoder;

fn build_for_platform(
    package_file: &Path,
    build_type: &str,
    platform: &str,
    sign_key: &Option<String>,
    output_dir: Option<&Path>,
    version_override: Option<&str>,
    sub_packages: Option<&Vec<String>>,
    quiet: bool,
) -> Result<()> {
    let pkg_lua_dir_str = package_file
        .parent()
        .and_then(Path::to_str)
        .ok_or_else(|| anyhow!("Could not get parent directory of package file"))?;
    let pkg_for_meta = pkg::lua::parser::parse_lua_package_for_platform(
        package_file.to_str().unwrap(),
        platform,
        version_override,
        quiet,
    )?;

    if !pkg_for_meta.types.iter().any(|t| t == build_type) {
        return Err(anyhow!(
            "Build type '{}' not supported by this package. Supported types: {:?}",
            build_type,
            pkg_for_meta.types
        ));
    }

    let version = if let Some(v) = version_override {
        v.to_string()
    } else {
        pkg::resolve::get_default_version(&pkg_for_meta, None)?
    };

    let build_dir = Builder::new()
        .prefix(&format!("zoi-build-{}-{}", pkg_for_meta.name, platform))
        .tempdir()?;
    if !quiet {
        println!("Using build directory: {}", build_dir.path().display());
    }
    let staging_dir = build_dir.path().join("staging");
    fs::create_dir_all(&staging_dir)?;

    let subs_to_build = if let Some(subs) = sub_packages {
        subs.clone()
    } else if let Some(subs) = &pkg_for_meta.sub_packages {
        subs.clone()
    } else {
        vec!["".to_string()]
    };

    for sub_package in subs_to_build {
        let sub_pkg_name = if sub_package.is_empty() {
            None
        } else {
            Some(sub_package.as_str())
        };

        if !sub_package.is_empty() && !quiet {
            println!("--- Building sub-package: {} ---", sub_package.cyan());
        }

        let lua = Lua::new();
        pkg::lua::functions::setup_lua_environment(
            &lua,
            platform,
            Some(&version),
            package_file.to_str(),
            None,
            sub_pkg_name,
            quiet,
        )
        .map_err(|e| {
            anyhow!(
                "Failed to setup Lua build environment for '{}': {}",
                package_file.display(),
                e
            )
        })?;
        let pkg_table = lua
            .to_value(&pkg_for_meta)
            .map_err(|e| anyhow!(e.to_string()))?;
        lua.globals()
            .set("PKG", pkg_table)
            .map_err(|e| anyhow!(e.to_string()))?;
        lua.globals()
            .set("BUILD_DIR", build_dir.path().to_str().unwrap())
            .map_err(|e| anyhow!(e.to_string()))?;
        lua.globals()
            .set("STAGING_DIR", staging_dir.to_str().unwrap())
            .map_err(|e| anyhow!(e.to_string()))?;
        lua.globals()
            .set("BUILD_TYPE", build_type)
            .map_err(|e| anyhow!(e.to_string()))?;

        let lua_code = fs::read_to_string(package_file)?;
        lua.load(&lua_code).exec().map_err(|e| {
            anyhow!(
                "Failed to execute Lua package file '{}' during build:\n{}",
                package_file.display(),
                e
            )
        })?;

        let args = lua.create_table().map_err(|e| anyhow!(e.to_string()))?;
        if !sub_package.is_empty() {
            args.set("sub", sub_package.clone())
                .map_err(|e| anyhow!(e.to_string()))?;
        }

        if let Ok(prepare_fn) = lua.globals().get::<mlua::Function>("prepare") {
            if !quiet {
                println!("Running prepare()...");
            }
            prepare_fn.call::<()>(args.clone()).map_err(|e| {
                anyhow!(
                    "The 'prepare' function in '{}' failed for sub-package '{}':\n{}",
                    package_file.display(),
                    sub_package,
                    e
                )
            })?;
        }

        if let Ok(package_fn) = lua.globals().get::<mlua::Function>("package") {
            if !quiet {
                println!("Running package()...");
            }
            package_fn.call::<()>(args.clone()).map_err(|e| {
                anyhow!(
                    "The 'package' function in '{}' failed for sub-package '{}':\n{}",
                    package_file.display(),
                    sub_package,
                    e
                )
            })?;
        }

        if let Ok(build_ops) = lua.globals().get::<Table>("__ZoiBuildOperations") {
            for op in build_ops.sequence_values::<Table>() {
                let op = op.map_err(|e| anyhow!(e.to_string()))?;
                if let Ok(op_type) = op.get::<String>("op")
                    && op_type == "zcp"
                {
                    let source: String = op.get("source").map_err(|e| anyhow!(e.to_string()))?;
                    let mut destination: String =
                        op.get("destination").map_err(|e| anyhow!(e.to_string()))?;

                    let source_path = if source.contains("${pkgluadir}") {
                        Path::new(&source.replace("${pkgluadir}", pkg_lua_dir_str)).to_path_buf()
                    } else {
                        build_dir.path().join(&source)
                    };
                    let data_prefix = if sub_package.is_empty() {
                        "data".to_string()
                    } else {
                        format!("data/{}", sub_package)
                    };

                    destination =
                        destination.replace("${pkgstore}", &format!("{}/pkgstore", data_prefix));
                    destination = destination
                        .replace("${createpkgdir}", &format!("{}/createpkgdir", data_prefix));
                    destination =
                        destination.replace("${usrroot}", &format!("{}/usrroot", data_prefix));
                    destination =
                        destination.replace("${usrhome}", &format!("{}/usrhome", data_prefix));

                    let dest_path = staging_dir.join(&destination);

                    if let Some(parent) = dest_path.parent() {
                        fs::create_dir_all(parent)?;
                    }

                    if source_path.is_dir() {
                        for entry in WalkDir::new(&source_path)
                            .into_iter()
                            .filter_map(|e| e.ok())
                        {
                            let target_path =
                                dest_path.join(entry.path().strip_prefix(&source_path)?);
                            if entry.file_type().is_dir() {
                                fs::create_dir_all(&target_path)?;
                            } else {
                                if let Some(p) = target_path.parent() {
                                    fs::create_dir_all(p)?;
                                }
                                fs::copy(entry.path(), &target_path)?;
                            }
                        }
                    } else {
                        fs::copy(&source_path, &dest_path)?;
                    }
                    if !quiet {
                        println!("Staged '{}' to '{}'", source, destination);
                    }
                }
            }
        }

        if let Ok(verify_fn) = lua.globals().get::<mlua::Function>("verify") {
            if !quiet {
                println!("Running verify()...");
            }
            let verification_passed: bool = verify_fn.call::<bool>(args.clone()).map_err(|e| {
                anyhow!(
                    "The 'verify' function in '{}' failed for sub-package '{}':\n{}",
                    package_file.display(),
                    sub_package,
                    e
                )
            })?;
            if !verification_passed {
                if !utils::ask_for_confirmation(
                    "Package verification failed. This package may be unsafe. Continue?",
                    false,
                ) {
                    return Err(anyhow!(
                        "Build aborted by user due to verification failure."
                    ));
                }
            } else if !quiet {
                println!("Package verification passed.");
            }
        }
    }

    let mut files_list = Vec::new();
    for entry in WalkDir::new(&staging_dir) {
        let entry = entry?;
        if entry.file_type().is_file()
            && let Ok(relative_path) = entry.path().strip_prefix(&staging_dir)
        {
            files_list.push(relative_path.to_string_lossy().replace('\\', "/"));
        }
    }
    files_list.sort();

    let manifest_content = files_list.join("\n  - ").to_string();
    fs::write(staging_dir.join("manifest.yaml"), manifest_content)?;

    fs::copy(
        package_file,
        staging_dir.join(package_file.file_name().unwrap()),
    )?;

    let output_filename = format!("{}-{}-{}.pkg.tar.zst", pkg_for_meta.name, version, platform);
    let output_base = if let Some(dir) = output_dir {
        dir.to_path_buf()
    } else {
        package_file.parent().unwrap().to_path_buf()
    };
    let output_path = output_base.join(output_filename);

    let file = File::create(&output_path)?;
    let encoder = ZstdEncoder::new(file, 0)?.auto_finish();
    let mut tar_builder = TarBuilder::new(encoder);
    tar_builder.append_dir_all(".", &staging_dir)?;
    tar_builder.finish()?;

    let files_manifest_path = output_path.with_extension("pkg.tar.zst.files");
    fs::write(&files_manifest_path, files_list.join("\n"))?;

    let hash_path = output_path.with_extension("pkg.tar.zst.hash");
    let hash = pkg::helper::get_hash(output_path.to_str().unwrap(), pkg::helper::HashType::Sha512)?;
    fs::write(
        &hash_path,
        format!(
            "{}  {}\n",
            hash,
            output_path.file_name().unwrap().to_str().unwrap()
        ),
    )?;

    let size_path = output_path.with_extension("pkg.tar.zst.size");
    let compressed_size = fs::metadata(&output_path)?.len();
    let uncompressed_size: u64 = WalkDir::new(&staging_dir)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().is_file())
        .map(|e| e.metadata().unwrap().len())
        .sum();
    fs::write(
        &size_path,
        format!(
            "down: {}\ninstall: {}\n",
            compressed_size, uncompressed_size
        ),
    )?;

    if !quiet {
        println!(
            "{}",
            format!("Successfully built package: {}", output_path.display()).green()
        );
    }

    if let Some(key_id) = sign_key {
        if !quiet {
            println!("Signing package with key '{}'...", key_id.cyan());
        }
        let signature_path = output_path.with_extension("pkg.tar.zst.sig");
        if signature_path.exists() {
            fs::remove_file(&signature_path)?;
        }
        pkg::pgp::sign_detached(&output_path, &signature_path, key_id)?;
        if !quiet {
            println!(
                "{}",
                format!(
                    "Successfully created signature: {}",
                    signature_path.display()
                )
                .green()
            );
        }
    }

    Ok(())
}

pub fn run(
    package_file: &Path,
    build_type: &str,
    platforms: &[String],
    sign_key: Option<String>,
    output_dir: Option<&Path>,
    version_override: Option<&str>,
    sub_packages: Option<Vec<String>>,
    quiet: bool,
) -> Result<()> {
    if !quiet {
        println!("Building package from: {}", package_file.display());
    }

    let platforms_to_build: Vec<String> = if platforms.contains(&"current".to_string()) {
        let mut p = platforms.to_vec();
        p.retain(|x| x != "current");
        p.push(utils::get_platform()?);
        p
    } else {
        platforms.to_vec()
    };

    if platforms.contains(&"all".to_string()) {
        return Err(anyhow!(
            "Building for 'all' platforms is not supported in this flow yet. Please specify platforms explicitly."
        ));
    }

    for platform in &platforms_to_build {
        if !quiet {
            println!("--- Building for platform: {} ---", platform.cyan());
        }
        if let Err(e) = build_for_platform(
            package_file,
            build_type,
            platform,
            &sign_key,
            output_dir,
            version_override,
            sub_packages.as_ref(),
            quiet,
        ) {
            eprintln!(
                "{}: Failed to build for platform {}: {}",
                "Error".red().bold(),
                platform.red(),
                e
            );
        }
    }

    Ok(())
}