use std::{
collections::HashMap,
fs::{self, File},
io::{Read, Seek, Write},
path::{Path, PathBuf},
};
use crate::{
chunk::ModpkgChunk, error::ModpkgError, LayerHash, Modpkg, LICENSE_CHUNK_PATH,
README_CHUNK_PATH, THUMBNAIL_CHUNK_PATH,
};
pub struct ModpkgExtractor<'modpkg, TSource: Read + Seek> {
modpkg: &'modpkg mut Modpkg<TSource>,
}
impl<'modpkg, TSource: Read + Seek> ModpkgExtractor<'modpkg, TSource> {
pub fn new(modpkg: &'modpkg mut Modpkg<TSource>) -> Self {
Self { modpkg }
}
pub fn extract_all(&mut self, output_dir: impl AsRef<Path>) -> Result<(), ModpkgError> {
let output_dir = output_dir.as_ref();
fs::create_dir_all(output_dir)?;
let mut chunks_by_layer: HashMap<LayerHash, Vec<ModpkgChunk>> = HashMap::new();
for (key, chunk) in &self.modpkg.chunks {
chunks_by_layer.entry(key.layer).or_default().push(*chunk);
}
for (layer_hash, chunks) in chunks_by_layer {
if layer_hash == LayerHash::NONE {
self.extract_meta_chunks(&chunks, output_dir)?;
continue;
}
let layer_name = match self.modpkg.layers.get(&layer_hash) {
Some(layer) => &layer.name,
None => continue, };
let layer_dir = output_dir.join(layer_name);
fs::create_dir_all(&layer_dir)?;
for chunk in chunks {
self.extract_chunk(&chunk, &layer_dir)?;
}
}
Ok(())
}
fn extract_meta_chunks(
&mut self,
chunks: &[ModpkgChunk],
output_dir: &Path,
) -> Result<(), ModpkgError> {
for chunk in chunks {
let Some(path) = self.modpkg.chunk_paths.get(&chunk.path_hash) else {
return Err(ModpkgError::MissingChunk(chunk.path_hash));
};
let Some(target) = meta_chunk_target(path) else {
continue;
};
self.write_chunk(chunk, &output_dir.join(target))?;
}
Ok(())
}
pub fn extract_chunk(
&mut self,
chunk: &ModpkgChunk,
output_dir: impl AsRef<Path>,
) -> Result<PathBuf, ModpkgError> {
let output_dir = output_dir.as_ref();
let path = match self.modpkg.chunk_paths.get(&chunk.path_hash) {
Some(path) => path,
None => return Err(ModpkgError::MissingChunk(chunk.path_hash)),
};
let output_path = output_dir.join(path);
self.write_chunk(chunk, &output_path)?;
Ok(output_path)
}
fn write_chunk(&mut self, chunk: &ModpkgChunk, output_path: &Path) -> Result<(), ModpkgError> {
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
let data = self.modpkg.decoder().load_chunk_decompressed(chunk)?;
let mut file = File::create(output_path)?;
file.write_all(&data)?;
Ok(())
}
pub fn extract_chunk_by_path(
&mut self,
path: &str,
layer: &str,
output_dir: impl AsRef<Path>,
) -> Result<PathBuf, ModpkgError> {
let chunk = *self.modpkg.chunk(path, Some(layer))?;
self.extract_chunk(&chunk, output_dir)
}
}
fn meta_chunk_target(chunk_path: &str) -> Option<&'static str> {
match chunk_path {
LICENSE_CHUNK_PATH => Some("LICENSE"),
README_CHUNK_PATH => Some("README.md"),
THUMBNAIL_CHUNK_PATH => Some("thumbnail.webp"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
builder::{ModpkgBuilder, ModpkgChunkBuilder, ModpkgLayerBuilder},
ModpkgCompression,
};
use std::io::Cursor;
use tempfile::tempdir;
#[test]
fn test_extractor() {
let scratch = Vec::new();
let mut cursor = Cursor::new(scratch);
let test_data = [0xAA; 100];
let path = "test.bin";
let layer_name = "base";
let builder = ModpkgBuilder::default()
.with_layer(ModpkgLayerBuilder::base())
.with_chunk(
ModpkgChunkBuilder::new()
.with_path(path)
.with_compression(ModpkgCompression::None),
);
builder
.build_to_writer(&mut cursor, |_| Ok(test_data.to_vec()))
.expect("Failed to build Modpkg");
cursor.set_position(0);
let mut modpkg = Modpkg::mount_from_reader(cursor).unwrap();
let temp_dir = tempdir().unwrap();
let output_dir = temp_dir.path();
let mut extractor = ModpkgExtractor::new(&mut modpkg);
extractor.extract_all(output_dir).unwrap();
let extracted_file = output_dir.join(layer_name).join(path);
assert!(extracted_file.exists());
let extracted_data = fs::read(extracted_file).unwrap();
assert_eq!(extracted_data, test_data);
}
#[test]
fn test_extract_meta_chunks() {
let mut cursor = Cursor::new(Vec::new());
let license_text = "MIT License\n\nCopyright (c) 2026 Someone\n";
let readme = "# My Mod\n";
let thumbnail = vec![0xCC; 64];
ModpkgBuilder::default()
.with_layer(ModpkgLayerBuilder::base())
.with_readme(readme)
.with_license_text(license_text)
.with_thumbnail(thumbnail.clone())
.with_chunk(
ModpkgChunkBuilder::new()
.with_path("test.bin")
.with_compression(ModpkgCompression::None),
)
.build_to_writer(&mut cursor, |_| Ok(vec![0xAA; 100]))
.expect("Failed to build Modpkg");
cursor.set_position(0);
let mut modpkg = Modpkg::mount_from_reader(cursor).unwrap();
let temp_dir = tempdir().unwrap();
let output_dir = temp_dir.path();
ModpkgExtractor::new(&mut modpkg)
.extract_all(output_dir)
.unwrap();
assert_eq!(
fs::read(output_dir.join("LICENSE")).unwrap(),
license_text.as_bytes()
);
assert_eq!(
fs::read(output_dir.join("README.md")).unwrap(),
readme.as_bytes()
);
assert_eq!(
fs::read(output_dir.join("thumbnail.webp")).unwrap(),
thumbnail
);
assert!(output_dir.join("base").join("test.bin").exists());
assert!(!output_dir.join("_meta_").exists());
}
#[test]
fn test_extract_multiple_layers() {
let scratch = Vec::new();
let mut cursor = Cursor::new(scratch);
let base_data = [0xAA; 100];
let custom_data = [0xBB; 100];
let path = "test.bin";
let base_layer = "base";
let custom_layer = "custom";
let builder = ModpkgBuilder::default()
.with_layer(ModpkgLayerBuilder::base())
.with_layer(
ModpkgLayerBuilder::new(custom_layer)
.unwrap()
.with_priority(1),
)
.with_chunk(
ModpkgChunkBuilder::new()
.with_path(path)
.with_compression(ModpkgCompression::None)
.with_layer(base_layer),
)
.with_chunk(
ModpkgChunkBuilder::new()
.with_path(path)
.with_compression(ModpkgCompression::None)
.with_layer(custom_layer),
);
builder
.build_to_writer(&mut cursor, |chunk| {
if chunk.layer() == base_layer {
Ok(base_data.to_vec())
} else {
Ok(custom_data.to_vec())
}
})
.expect("Failed to build Modpkg");
cursor.set_position(0);
let mut modpkg = Modpkg::mount_from_reader(cursor).unwrap();
let temp_dir = tempdir().unwrap();
let output_dir = temp_dir.path();
let mut extractor = ModpkgExtractor::new(&mut modpkg);
extractor.extract_all(output_dir).unwrap();
let base_file = output_dir.join(base_layer).join(path);
let custom_file = output_dir.join(custom_layer).join(path);
assert!(base_file.exists());
assert!(custom_file.exists());
let extracted_base_data = fs::read(base_file).unwrap();
let extracted_custom_data = fs::read(custom_file).unwrap();
assert_eq!(extracted_base_data, base_data);
assert_eq!(extracted_custom_data, custom_data);
}
}