Skip to main content

zoi_package/
bundle.rs

1//! Logic for creating Zoi Source Archives (`.zsa`).
2//!
3//! This module handles the "bundling" process, which packages a `.pkg.lua`
4//! file together with its local assets and fetched upstream sources into
5//! a single, self-contained archive. This is useful for offline builds
6//! and for distributing source code along with build instructions.
7
8use std::collections::HashSet;
9use std::fs::{self, File};
10use std::path::{Path, PathBuf};
11
12use anyhow::{Result, anyhow};
13use colored::Colorize;
14use ignore::gitignore::GitignoreBuilder;
15use mlua::{Lua, LuaSerdeExt, Table, Value};
16use tar::Builder as TarBuilder;
17use tempfile::Builder;
18use walkdir::WalkDir;
19use zstd::stream::write::Encoder as ZstdEncoder;
20
21/// Bundles a package and its dependencies into a `.zsa` archive.
22///
23/// Returns the path of the created archive.
24///
25/// # Errors
26///
27/// Returns an error if:
28/// - Parsing the `.pkg.lua` file fails.
29/// - Upstream sources cannot be fetched.
30/// - The archive cannot be created or signed.
31pub fn run(
32    package_file: &Path,
33    output_dir: Option<&Path>,
34    sign: Option<String>,
35    version_override: Option<&str>,
36    build_type: Option<&str>
37) -> Result<PathBuf> {
38    let pkg_dir = package_file
39        .parent()
40        .ok_or_else(|| anyhow!("Could not get parent directory"))?;
41
42    // Load .zoiignore if it exists
43    let mut ignore_builder = GitignoreBuilder::new(pkg_dir);
44    let zoiignore_path = pkg_dir.join(".zoiignore");
45    if zoiignore_path.exists()
46        && let Some(err) = ignore_builder.add(&zoiignore_path)
47    {
48        eprintln!("{}: Error parsing .zoiignore: {}", "Warning".yellow(), err);
49    }
50    let ignore = ignore_builder.build()?;
51
52    let is_ignored = |rel_path: &Path, is_dir: bool| -> bool {
53        ignore.matched(rel_path, is_dir).is_ignore()
54    };
55
56    println!(
57        "{} Bundling package: {}",
58        "::".bold().blue(),
59        package_file.display()
60    );
61
62    let lua = Lua::new();
63    let platform = zoi_core::utils::get_platform()?;
64
65    // Initialize global tables for tracking
66    let refs_table = lua.create_table().map_err(|e| anyhow!(e.to_string()))?;
67    lua.globals()
68        .set("__ZoiReferencedFiles", refs_table)
69        .map_err(|e| anyhow!(e.to_string()))?;
70
71    // Setup a mocked environment for metadata and asset discovery
72    // We run it twice: once to find local assets, and once to actually run
73    // prepare() if needed.
74
75    let bundle_type = build_type.unwrap_or("source");
76
77    // Phase 1: Metadata & Local Asset Discovery
78    zoi_lua::functions::setup_lua_environment(
79        &lua,
80        &platform,
81        version_override,
82        package_file.to_str(),
83        None,
84        Some("/tmp/mock-build"),
85        Some("/tmp/mock-staging"),
86        None,
87        None,
88        Some(bundle_type),
89        true // quiet
90    )
91    .map_err(|e| anyhow!(e.to_string()))?;
92
93    lua.globals()
94        .set("BUILD_TYPE", bundle_type)
95        .map_err(|e| anyhow!(e.to_string()))?;
96
97    // Mock UTILS.EXTRACT to record local references but avoid downloads (in
98    // this phase)
99    if let Ok(utils) = lua.globals().get::<Table>("UTILS") {
100        let mock_extract = lua
101            .create_function(|_, (_source, _out_dir): (String, String)| Ok(()))
102            .map_err(|e| anyhow!(e.to_string()))?;
103        utils
104            .set("EXTRACT", mock_extract)
105            .map_err(|e| anyhow!(e.to_string()))?;
106    }
107
108    // Mock cmd to avoid shell execution in this phase
109    let mock_cmd = lua
110        .create_function(|_, _command: String| {
111            Ok((String::new(), String::new(), 0))
112        })
113        .map_err(|e| anyhow!(e.to_string()))?;
114    lua.globals()
115        .set("cmd", mock_cmd)
116        .map_err(|e| anyhow!(e.to_string()))?;
117
118    // Load and execute the package file
119    let lua_code = fs::read_to_string(package_file)?;
120    lua.load(&lua_code).exec().map_err(|e| {
121        anyhow!(
122            "Failed to execute Lua package file '{}' for bundling:\n{}",
123            package_file.display(),
124            e
125        )
126    })?;
127
128    let args = lua.create_table().map_err(|e| anyhow!(e.to_string()))?;
129
130    // Call lifecycle functions to find ${pkgluadir} references
131    if let Ok(pkg_fn) = lua.globals().get::<mlua::Function>("package") {
132        let _ = pkg_fn.call::<()>(args.clone());
133    }
134
135    let mut files_to_include = HashSet::new();
136
137    // Always include the package file itself
138    let pkg_filename = package_file
139        .file_name()
140        .ok_or_else(|| anyhow!("Invalid package file"))?;
141    files_to_include.insert(pkg_filename.to_string_lossy().to_string());
142
143    // Collect from __ZoiReferencedFiles (IMPORT/INCLUDE)
144    if let Ok(refs) = lua.globals().get::<Table>("__ZoiReferencedFiles") {
145        for val in refs.sequence_values::<String>() {
146            files_to_include.insert(val.map_err(|e| anyhow!(e.to_string()))?);
147        }
148    }
149
150    // Collect from __ZoiBuildOperations (zcp/zln with ${pkgluadir})
151    if let Ok(ops) = lua.globals().get::<Table>("__ZoiBuildOperations") {
152        for op in ops.sequence_values::<Table>() {
153            let op = op.map_err(|e| anyhow!(e.to_string()))?;
154
155            // Check 'source' (used by zcp)
156            if let Ok(source) = op.get::<String>("source")
157                && let Some(rel) = source.strip_prefix("${pkgluadir}/")
158            {
159                files_to_include.insert(rel.to_string());
160            }
161
162            // Check 'target' (used by zln)
163            if let Ok(target) = op.get::<String>("target")
164                && let Some(rel) = target.strip_prefix("${pkgluadir}/")
165            {
166                files_to_include.insert(rel.to_string());
167            }
168        }
169    }
170
171    // Phase 2: Fetching Upstream Sources (Running prepare)
172    println!("{} Fetching upstream sources...", "::".bold().blue());
173    let fetch_dir = Builder::new().prefix("zoi-bundle-fetch-").tempdir()?;
174
175    // Setup a real environment for prepare()
176    let lua_fetch = Lua::new();
177
178    // Initialize package metadata tables for the fetch state
179    let pkg_meta_table_f = lua_fetch
180        .create_table()
181        .map_err(|e| anyhow!(e.to_string()))?;
182    let pkg_deps_table_f = lua_fetch
183        .create_table()
184        .map_err(|e| anyhow!(e.to_string()))?;
185    let pkg_updates_table_f = lua_fetch
186        .create_table()
187        .map_err(|e| anyhow!(e.to_string()))?;
188    let pkg_hooks_table_f = lua_fetch
189        .create_table()
190        .map_err(|e| anyhow!(e.to_string()))?;
191    let pkg_service_table_f = lua_fetch
192        .create_table()
193        .map_err(|e| anyhow!(e.to_string()))?;
194    lua_fetch
195        .globals()
196        .set("__ZoiPackageMeta", pkg_meta_table_f)
197        .map_err(|e| anyhow!(e.to_string()))?;
198    lua_fetch
199        .globals()
200        .set("__ZoiPackageDeps", pkg_deps_table_f)
201        .map_err(|e| anyhow!(e.to_string()))?;
202    lua_fetch
203        .globals()
204        .set("__ZoiPackageUpdates", pkg_updates_table_f)
205        .map_err(|e| anyhow!(e.to_string()))?;
206    lua_fetch
207        .globals()
208        .set("__ZoiPackageHooks", pkg_hooks_table_f)
209        .map_err(|e| anyhow!(e.to_string()))?;
210    lua_fetch
211        .globals()
212        .set("__ZoiPackageService", pkg_service_table_f)
213        .map_err(|e| anyhow!(e.to_string()))?;
214
215    let pkg_global_table_f = lua_fetch
216        .create_table()
217        .map_err(|e| anyhow!(e.to_string()))?;
218    lua_fetch
219        .globals()
220        .set("PKG", pkg_global_table_f)
221        .map_err(|e| anyhow!(e.to_string()))?;
222
223    zoi_lua::functions::setup_lua_environment(
224        &lua_fetch,
225        &platform,
226        version_override,
227        package_file.to_str(),
228        None,
229        Some(fetch_dir.path().to_str().unwrap_or("")),
230        Some("/tmp/mock-staging"),
231        None,
232        None,
233        Some(bundle_type),
234        true // quiet
235    )
236    .map_err(|e| anyhow!(e.to_string()))?;
237
238    lua_fetch
239        .globals()
240        .set("BUILD_TYPE", bundle_type)
241        .map_err(|e| anyhow!(e.to_string()))?;
242
243    lua_fetch
244        .globals()
245        .set(
246            "BUILD_DIR",
247            fetch_dir
248                .path()
249                .to_str()
250                .ok_or_else(|| anyhow!("Invalid fetch path"))?
251        )
252        .map_err(|e| anyhow!(e.to_string()))?;
253
254    // We use the real cmd implementation for fetching
255    zoi_lua::api::system::add_cmd_util(&lua_fetch, true)
256        .map_err(|e| anyhow!(e.to_string()))?;
257
258    // Reload script in the fetch environment
259    lua_fetch.load(&lua_code).exec().map_err(|e| {
260        anyhow!(
261            "Failed to execute Lua package file '{}' during fetch:\n{}",
262            package_file.display(),
263            e
264        )
265    })?;
266
267    if let Ok(prep_fn) = lua_fetch.globals().get::<mlua::Function>("prepare") {
268        println!("  Running prepare()...");
269        let args_fetch = lua_fetch
270            .create_table()
271            .map_err(|e| anyhow!(e.to_string()))?;
272        prep_fn.call::<()>(args_fetch).map_err(|e| {
273            anyhow!(
274                "The 'prepare' function in '{}' failed during bundling:\n{}",
275                package_file.display(),
276                e
277            )
278        })?;
279    }
280
281    // Determine output path
282    let pkg_dir = package_file
283        .parent()
284        .ok_or_else(|| anyhow!("Could not get parent directory"))?;
285
286    let final_pkg_meta: Table = lua
287        .globals()
288        .get("__ZoiPackageMeta")
289        .map_err(|e| anyhow!(e.to_string()))?;
290    let pkg_meta: zoi_core::types::Package = lua
291        .from_value(Value::Table(final_pkg_meta))
292        .map_err(|e| anyhow!(e.to_string()))?;
293
294    let version = version_override
295        .map(ToString::to_string)
296        .or(pkg_meta.version)
297        .unwrap_or_else(|| "unknown".to_string());
298    let output_filename = format!("{}-{}.zsa", pkg_meta.name, version);
299    let output_base =
300        output_dir.map_or_else(|| pkg_dir.to_path_buf(), Path::to_path_buf);
301    let output_path = output_base.join(output_filename);
302
303    let file = File::create(&output_path)?;
304    let encoder = ZstdEncoder::new(file, 0)?.auto_finish();
305    let mut tar_builder = TarBuilder::new(encoder);
306
307    // Include local files
308    let mut sorted_files: Vec<_> = files_to_include.into_iter().collect();
309    sorted_files.sort();
310
311    for rel_path_str in sorted_files {
312        let rel_path = Path::new(&rel_path_str);
313        let abs_path = pkg_dir.join(rel_path);
314        let is_dir = abs_path.is_dir();
315
316        if is_ignored(rel_path, is_dir) {
317            println!("  Ignored: {rel_path_str}");
318            continue;
319        }
320
321        if abs_path.exists() {
322            if is_dir {
323                // Manually walk local directories to respect ignores
324                // recursively
325                let mut it = WalkDir::new(&abs_path).into_iter();
326                loop {
327                    let entry = match it.next() {
328                        None => break,
329                        Some(Err(e)) => return Err(e.into()),
330                        Some(Ok(e)) => e
331                    };
332                    let entry_rel = entry.path().strip_prefix(pkg_dir)?;
333                    let entry_is_dir = entry.file_type().is_dir();
334                    if is_ignored(entry_rel, entry_is_dir) {
335                        if entry_is_dir {
336                            it.skip_current_dir();
337                        }
338                        continue;
339                    }
340
341                    if entry.file_type().is_file() {
342                        tar_builder
343                            .append_path_with_name(entry.path(), entry_rel)?;
344                        println!("  Included local: {}", entry_rel.display());
345                    }
346                }
347            } else {
348                tar_builder.append_path_with_name(&abs_path, &rel_path_str)?;
349                println!("  Included local: {rel_path_str}");
350            }
351        }
352    }
353
354    // Include fetched files from BUILD_DIR
355    let mut it = WalkDir::new(fetch_dir.path()).into_iter();
356    loop {
357        let entry = match it.next() {
358            None => break,
359            Some(Err(e)) => return Err(e.into()),
360            Some(Ok(e)) => e
361        };
362
363        if entry.depth() == 0 {
364            continue;
365        }
366
367        let rel_path = entry.path().strip_prefix(fetch_dir.path())?;
368        let rel_path_str = rel_path.to_string_lossy();
369        let is_dir = entry.file_type().is_dir();
370
371        if is_ignored(rel_path, is_dir) {
372            if is_dir {
373                it.skip_current_dir();
374            }
375            println!("  Ignored fetch: {rel_path_str}");
376            continue;
377        }
378
379        if entry.file_type().is_dir() {
380            // We'll add directories as we encounter their files or empty dirs
381            continue;
382        }
383
384        tar_builder.append_path_with_name(entry.path(), rel_path)?;
385        println!("  Included fetch: {rel_path_str}");
386    }
387
388    // Mark as a full bundle so build knows to skip prepare
389    let mut header = tar::Header::new_gnu();
390    header.set_path(".zoi-prepared")?;
391    header.set_size(0);
392    header.set_cksum();
393    tar_builder.append(&header, &[][..])?;
394
395    tar_builder.finish()?;
396    println!(
397        "{} Successfully created bundle: {}",
398        "::".bold().green(),
399        output_path.display()
400    );
401
402    if let Some(key_id) = sign {
403        println!(
404            "{} Signing bundle with key '{}'...",
405            "::".bold().blue(),
406            key_id.cyan()
407        );
408        let signature_path =
409            PathBuf::from(format!("{}.sig", output_path.display()));
410        if signature_path.exists() {
411            fs::remove_file(&signature_path)?;
412        }
413        zoi_core::pgp::sign_detached(&output_path, &signature_path, &key_id)?;
414        println!(
415            "{} Successfully created signature: {}",
416            "::".bold().green(),
417            signature_path.display()
418        );
419    }
420
421    Ok(output_path)
422}