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