Skip to main content

zoi_package/
bundle.rs

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