1use anyhow::{anyhow, Context, Result};
47use crossbeam_channel::Sender;
48use khora_core::asset::{AssetSource, CompressionKind};
49use std::{
50 fs::File,
51 io::Write,
52 path::{Path, PathBuf},
53};
54
55use super::{
56 AssetIdRegistry, IndexBuilder, PackLoader, PackManifest, PACK_FLAG_LZ4, PACK_FLAG_MANIFEST,
57};
58
59#[derive(Debug, Clone)]
61pub enum PackProgress {
62 Started {
64 rel_path: String,
66 current: usize,
68 total: usize,
70 },
71 Finished {
73 asset_count: usize,
75 pack_bytes: u64,
77 },
78 Failed {
81 message: String,
83 },
84}
85
86#[derive(Debug, Clone)]
88pub struct PackOutput {
89 pub index_bin: PathBuf,
91 pub data_pack: PathBuf,
93 pub manifest_bin: Option<PathBuf>,
96 pub asset_count: usize,
98 pack_bytes: u64,
100}
101
102impl PackOutput {
103 pub fn pack_bytes(&self) -> u64 {
105 self.pack_bytes
106 }
107}
108
109pub struct PackBuilder<'a> {
124 assets_root: &'a Path,
125 dest_dir: &'a Path,
126 progress: Option<Sender<PackProgress>>,
127 compression: CompressionKind,
128 write_manifest: bool,
129}
130
131impl<'a> PackBuilder<'a> {
132 pub fn new(assets_root: &'a Path, dest_dir: &'a Path) -> Self {
137 Self {
138 assets_root,
139 dest_dir,
140 progress: None,
141 compression: CompressionKind::None,
142 write_manifest: false,
143 }
144 }
145
146 pub fn with_progress(mut self, tx: Sender<PackProgress>) -> Self {
149 self.progress = Some(tx);
150 self
151 }
152
153 pub fn with_compression(mut self, compression: CompressionKind) -> Self {
158 self.compression = compression;
159 self
160 }
161
162 pub fn with_manifest(mut self, enable: bool) -> Self {
167 self.write_manifest = enable;
168 self
169 }
170
171 pub fn build(self) -> Result<PackOutput> {
180 std::fs::create_dir_all(self.dest_dir).with_context(|| {
181 format!(
182 "Failed to create pack destination: {}",
183 self.dest_dir.display()
184 )
185 })?;
186
187 let project_root = self.assets_root.parent().unwrap_or(self.assets_root);
192 let registry = AssetIdRegistry::load(project_root);
193 let mut metadata = IndexBuilder::new(self.assets_root)
194 .with_registry(®istry)
195 .build_metadata()
196 .context("Pack: failed to build asset metadata")?;
197 let total = metadata.len();
198
199 let data_pack_path = self.dest_dir.join("data.pack");
200 let mut data_file = File::create(&data_pack_path)
201 .with_context(|| format!("Failed to create {}", data_pack_path.display()))?;
202
203 let mut flags = 0u32;
208 if self.compression == CompressionKind::Lz4 {
209 flags |= PACK_FLAG_LZ4;
210 }
211 if self.write_manifest {
212 flags |= PACK_FLAG_MANIFEST;
213 }
214 PackLoader::write_header(&mut data_file, total as u32, flags)
215 .context("Failed to write pack header")?;
216
217 let mut manifest = if self.write_manifest {
218 Some(PackManifest::new())
219 } else {
220 None
221 };
222
223 let mut offset = 0u64;
224 for (idx, meta) in metadata.iter_mut().enumerate() {
225 let rel_path = match meta.variants.get("default") {
228 Some(AssetSource::Path(p)) => p.clone(),
229 Some(AssetSource::Packed { .. }) => {
230 return Err(anyhow!(
231 "Pack: asset {:?} already has a Packed variant; \
232 IndexBuilder must produce Path variants",
233 meta.uuid
234 ));
235 }
236 None => {
237 return Err(anyhow!(
238 "Pack: asset {:?} has no 'default' variant",
239 meta.uuid
240 ));
241 }
242 };
243
244 let rel_str = rel_to_forward_slash(&rel_path);
245 self.send_progress(PackProgress::Started {
246 rel_path: rel_str.clone(),
247 current: idx,
248 total,
249 });
250
251 let abs = self.assets_root.join(&rel_path);
252 let raw = std::fs::read(&abs)
253 .with_context(|| format!("Failed to read asset bytes: {}", abs.display()))?;
254 let uncompressed_size = raw.len() as u64;
255
256 if let Some(m) = manifest.as_mut() {
260 m.insert(meta.uuid, &raw);
261 }
262
263 let (bytes_to_write, compression) = match self.compression {
264 CompressionKind::None => (raw, CompressionKind::None),
265 CompressionKind::Lz4 => {
266 let compressed = lz4_flex::block::compress(&raw);
267 if compressed.len() < raw.len() {
270 (compressed, CompressionKind::Lz4)
271 } else {
272 (raw, CompressionKind::None)
273 }
274 }
275 };
276 let on_disk_size = bytes_to_write.len() as u64;
277
278 data_file
279 .write_all(&bytes_to_write)
280 .with_context(|| format!("Failed to append {} to data.pack", rel_str))?;
281
282 meta.variants.insert(
283 "default".to_string(),
284 AssetSource::Packed {
285 offset,
286 size: on_disk_size,
287 uncompressed_size,
288 compression,
289 },
290 );
291
292 offset += on_disk_size;
293 }
294
295 data_file.flush().context("Failed to flush data.pack")?;
298 drop(data_file);
299
300 let index_bin_path = self.dest_dir.join("index.bin");
301 let cfg = bincode::config::standard();
302 let encoded = bincode::serde::encode_to_vec(&metadata, cfg)
303 .map_err(|e| anyhow!("Failed to encode index: {}", e))?;
304 std::fs::write(&index_bin_path, &encoded)
305 .with_context(|| format!("Failed to write {}", index_bin_path.display()))?;
306
307 let manifest_bin = if let Some(m) = manifest {
308 let manifest_path = self.dest_dir.join("manifest.bin");
309 let manifest_bytes = m.encode().context("Failed to encode manifest")?;
310 std::fs::write(&manifest_path, &manifest_bytes)
311 .with_context(|| format!("Failed to write {}", manifest_path.display()))?;
312 Some(manifest_path)
313 } else {
314 None
315 };
316
317 let pack_bytes = offset;
318 self.send_progress(PackProgress::Finished {
319 asset_count: total,
320 pack_bytes,
321 });
322
323 Ok(PackOutput {
324 index_bin: index_bin_path,
325 data_pack: data_pack_path,
326 manifest_bin,
327 asset_count: total,
328 pack_bytes,
329 })
330 }
331
332 fn send_progress(&self, ev: PackProgress) {
333 if let Some(tx) = &self.progress {
334 let _ = tx.send(ev);
336 }
337 }
338}
339
340fn rel_to_forward_slash(rel: &Path) -> String {
341 rel.components()
342 .map(|c| c.as_os_str().to_string_lossy().into_owned())
343 .collect::<Vec<_>>()
344 .join("/")
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use crate::asset::{AssetService, PackLoader};
351 use crate::vfs::VirtualFileSystem;
352 use khora_core::asset::AssetUUID;
353 use khora_telemetry::MetricsRegistry;
354 use std::fs;
355 use std::sync::Arc;
356 use tempfile::tempdir;
357
358 fn seed_project(root: &Path) {
359 fs::create_dir_all(root.join("textures")).unwrap();
360 fs::create_dir_all(root.join("scenes")).unwrap();
361 fs::write(root.join("textures").join("a.png"), b"PNG-A").unwrap();
362 fs::write(root.join("textures").join("b.png"), b"PNG-B").unwrap();
363 fs::write(root.join("scenes").join("default.kscene"), b"SCENE").unwrap();
364 }
365
366 #[test]
367 fn build_produces_index_and_pack() {
368 let proj = tempdir().unwrap();
369 let dest = tempdir().unwrap();
370 seed_project(proj.path());
371
372 let out = PackBuilder::new(proj.path(), dest.path()).build().unwrap();
373
374 assert!(out.index_bin.exists());
375 assert!(out.data_pack.exists());
376 assert_eq!(out.asset_count, 3);
377 assert_eq!(out.pack_bytes(), 5 + 5 + 5); }
379
380 #[test]
381 fn build_is_deterministic() {
382 let proj = tempdir().unwrap();
383 let dest_a = tempdir().unwrap();
384 let dest_b = tempdir().unwrap();
385 seed_project(proj.path());
386
387 let _ = PackBuilder::new(proj.path(), dest_a.path())
388 .build()
389 .unwrap();
390 let _ = PackBuilder::new(proj.path(), dest_b.path())
391 .build()
392 .unwrap();
393
394 let idx_a = std::fs::read(dest_a.path().join("index.bin")).unwrap();
395 let idx_b = std::fs::read(dest_b.path().join("index.bin")).unwrap();
396 assert_eq!(idx_a, idx_b, "index.bin must be byte-deterministic");
397
398 let pack_a = std::fs::read(dest_a.path().join("data.pack")).unwrap();
399 let pack_b = std::fs::read(dest_b.path().join("data.pack")).unwrap();
400 assert_eq!(pack_a, pack_b, "data.pack must be byte-deterministic");
401 }
402
403 #[test]
404 fn pack_round_trips_through_packloader() {
405 let proj = tempdir().unwrap();
406 let dest = tempdir().unwrap();
407 seed_project(proj.path());
408
409 let out = PackBuilder::new(proj.path(), dest.path()).build().unwrap();
410
411 let index_bytes = std::fs::read(&out.index_bin).unwrap();
416 let vfs = VirtualFileSystem::new(&index_bytes).unwrap();
417 let pack_file = std::fs::File::open(&out.data_pack).unwrap();
418 let pack_loader = PackLoader::new(pack_file).expect("valid pack header");
419 assert_eq!(pack_loader.header().asset_count, vfs.asset_count() as u32);
420 let metrics = Arc::new(MetricsRegistry::new());
421 let mut svc =
422 AssetService::new(&index_bytes, Box::new(pack_loader), metrics, None).unwrap();
423
424 let known = [
427 ("scenes/default.kscene", &b"SCENE"[..]),
428 ("textures/a.png", &b"PNG-A"[..]),
429 ("textures/b.png", &b"PNG-B"[..]),
430 ];
431 for (rel, expected) in known {
432 let uuid = AssetUUID::new_v5(rel);
433 assert!(
434 vfs.get_metadata(&uuid).is_some(),
435 "uuid for {} missing",
436 rel
437 );
438 let bytes = svc.load_raw(&uuid).unwrap();
439 assert_eq!(bytes, expected, "round-trip mismatch for {}", rel);
440 }
441 }
442
443 #[test]
444 fn progress_channel_emits_started_and_finished() {
445 let proj = tempdir().unwrap();
446 let dest = tempdir().unwrap();
447 seed_project(proj.path());
448
449 let (tx, rx) = crossbeam_channel::unbounded::<PackProgress>();
450 let _ = PackBuilder::new(proj.path(), dest.path())
451 .with_progress(tx)
452 .build()
453 .unwrap();
454
455 let mut started = 0usize;
456 let mut finished = 0usize;
457 while let Ok(ev) = rx.try_recv() {
458 match ev {
459 PackProgress::Started { current, total, .. } => {
460 assert_eq!(total, 3);
461 assert!(current < total);
462 started += 1;
463 }
464 PackProgress::Finished { asset_count, .. } => {
465 assert_eq!(asset_count, 3);
466 finished += 1;
467 }
468 PackProgress::Failed { .. } => panic!("unexpected Failed event"),
469 }
470 }
471 assert_eq!(started, 3);
472 assert_eq!(finished, 1);
473 }
474
475 #[test]
476 fn empty_assets_root_produces_empty_pack() {
477 let proj = tempdir().unwrap();
478 let dest = tempdir().unwrap();
479 let out = PackBuilder::new(proj.path(), dest.path()).build().unwrap();
480 assert_eq!(out.asset_count, 0);
481 assert_eq!(out.pack_bytes(), 0);
482 assert!(out.data_pack.exists());
484 assert!(out.index_bin.exists());
485 }
486}