use std::{
fmt,
io::{Read, Seek},
};
use crate::{
chunk::ModpkgChunk, LayerIndex, Modpkg, WadIndex, HASHTABLES_CHUNK_DIR, LICENSE_CHUNK_PATH,
README_CHUNK_PATH, THUMBNAIL_CHUNK_PATH,
};
pub const HASHES_DIR_NAME: &str = "hashes";
#[derive(Debug, Clone, PartialEq)]
pub struct ExtractionPlan<'pkg> {
chunks: Vec<PlannedChunk<'pkg>>,
}
impl<'pkg> ExtractionPlan<'pkg> {
pub fn chunks(&self) -> &[PlannedChunk<'pkg>] {
&self.chunks
}
pub fn layer(&self, name: &str) -> Self {
self.retaining(
|destination| matches!(destination, ChunkDestination::Content { layer, .. } if *layer == name),
)
}
pub fn root_files(&self) -> Self {
self.retaining(|destination| matches!(destination, ChunkDestination::Root(_)))
}
pub(crate) fn meta_files(&self) -> Self {
self.retaining(|destination| !matches!(destination, ChunkDestination::Content { .. }))
}
fn retaining(&self, keep: impl Fn(&ChunkDestination<'pkg>) -> bool) -> Self {
Self {
chunks: self
.chunks
.iter()
.copied()
.filter(|planned| keep(&planned.destination))
.collect(),
}
}
}
impl<'a, 'pkg> IntoIterator for &'a ExtractionPlan<'pkg> {
type Item = &'a PlannedChunk<'pkg>;
type IntoIter = std::slice::Iter<'a, PlannedChunk<'pkg>>;
fn into_iter(self) -> Self::IntoIter {
self.chunks.iter()
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PlannedChunk<'pkg> {
pub chunk: ModpkgChunk,
pub destination: ChunkDestination<'pkg>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ChunkDestination<'pkg> {
Content {
layer: &'pkg str,
wad: Option<&'pkg str>,
path: &'pkg str,
},
Root(&'static str),
Hashtable {
file_name: &'pkg str,
},
}
impl ChunkDestination<'_> {
pub fn compose(&self) -> String {
self.to_string()
}
}
impl fmt::Display for ChunkDestination<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Self::Content {
layer,
wad: Some(wad),
path,
} => write!(f, "{layer}/{wad}/{path}"),
Self::Content {
layer,
wad: None,
path,
} => write!(f, "{layer}/{path}"),
Self::Root(file_name) => f.write_str(file_name),
Self::Hashtable { file_name } => write!(f, "{HASHES_DIR_NAME}/{file_name}"),
}
}
}
impl<TSource: Read + Seek> Modpkg<TSource> {
pub fn extraction_plan(&self) -> ExtractionPlan<'_> {
let mut groups: Vec<(WadIndex, LayerIndex)> =
self.chunks_by_wad_layer.keys().copied().collect();
groups.sort_by_key(|&(wad_index, layer_index)| {
let layer = self.layer_name_for_index(layer_index);
(layer.is_none(), layer, self.wad_name_for_index(wad_index))
});
let mut chunks = Vec::new();
for (wad_index, layer_index) in groups {
let wad = self.wad_name_for_index(wad_index);
let layer = self.layer_name_for_index(layer_index);
for key in self.chunks_for_wad_layer(wad_index, layer_index) {
let chunk = *self
.chunks
.get(key)
.expect("a grouped chunk key is in the chunk table");
let path = self
.chunk_path(&chunk)
.expect("a mounted chunk names a path table position");
let destination = match layer {
Some(layer) => ChunkDestination::Content { layer, wad, path },
None => match root_file_name(path) {
Some(file_name) => ChunkDestination::Root(file_name),
None => match hashtable_file_name(path) {
Some(file_name) => ChunkDestination::Hashtable { file_name },
None => continue,
},
},
};
chunks.push(PlannedChunk { chunk, destination });
}
}
ExtractionPlan { chunks }
}
}
fn root_file_name(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,
}
}
pub fn hashtable_file_name(chunk_path: &str) -> Option<&str> {
let tail = chunk_path
.strip_prefix(HASHTABLES_CHUNK_DIR)?
.strip_prefix('/')?;
let file_name = tail
.rsplit(['/', '\\'])
.next()
.expect("rsplit yields at least one part");
(!file_name.is_empty() && file_name != ".." && file_name != ".").then_some(file_name)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
builder::{ModpkgBuilder, ModpkgChunkBuilder, ModpkgLayerBuilder},
ModpkgCompression,
};
use std::io::Cursor;
fn content<'a>(layer: &'a str, wad: Option<&'a str>, path: &'a str) -> ChunkDestination<'a> {
ChunkDestination::Content { layer, wad, path }
}
#[test]
fn a_chunk_lands_under_its_layer_and_its_wad() {
assert_eq!(
content("base", Some("Aatrox.wad.client"), "data/x.bin").compose(),
"base/Aatrox.wad.client/data/x.bin"
);
}
#[test]
fn a_chunk_without_a_wad_stays_at_the_layer_root() {
assert_eq!(
content("base", None, "loose.bin").compose(),
"base/loose.bin"
);
}
#[test]
fn only_the_meta_chunks_with_a_file_form_have_a_destination() {
assert_eq!(root_file_name(README_CHUNK_PATH), Some("README.md"));
assert_eq!(root_file_name(LICENSE_CHUNK_PATH), Some("LICENSE"));
assert_eq!(root_file_name(THUMBNAIL_CHUNK_PATH), Some("thumbnail.webp"));
assert_eq!(root_file_name("_meta_/metadata"), None);
}
fn paths(plan: &ExtractionPlan<'_>) -> Vec<String> {
plan.chunks()
.iter()
.map(|planned| planned.destination.compose())
.collect()
}
fn package(build: impl FnOnce(ModpkgBuilder) -> ModpkgBuilder) -> Modpkg<Cursor<Vec<u8>>> {
let mut cursor = Cursor::new(Vec::new());
build(ModpkgBuilder::default().with_layer(ModpkgLayerBuilder::base()))
.build_to_writer(&mut cursor, |_| Ok(vec![0xAA; 10]))
.unwrap();
cursor.set_position(0);
Modpkg::mount_from_reader(cursor).unwrap()
}
fn chunk(path: &str) -> ModpkgChunkBuilder {
ModpkgChunkBuilder::new()
.with_path(path)
.with_compression(ModpkgCompression::None)
}
fn three_layers_and_a_readme() -> Modpkg<Cursor<Vec<u8>>> {
package(|builder| {
builder
.with_layer(ModpkgLayerBuilder::new("zed").unwrap().with_priority(2))
.with_layer(ModpkgLayerBuilder::new("aatrox").unwrap().with_priority(1))
.with_readme("# My Mod\n")
.with_chunk(chunk("x.bin").with_layer("zed"))
.with_chunk(chunk("x.bin").with_layer("aatrox"))
.with_chunk(chunk("x.bin"))
})
}
#[test]
fn layers_are_planned_in_name_order_and_the_meta_chunks_last() {
assert_eq!(
paths(&three_layers_and_a_readme().extraction_plan()),
["aatrox/x.bin", "base/x.bin", "zed/x.bin", "README.md"]
);
}
#[test]
fn a_hashtable_chunk_lands_under_the_hashes_directory() {
let modpkg = package(|builder| {
builder
.with_chunk(chunk("x.bin"))
.with_hashtable(
crate::ModpkgHashtable {
path: "_meta_/hashes/game.hashes.txt".to_string(),
category: ltk_hashtable::Category::Game,
algorithm: ltk_hashtable::Algorithm::Xxh64,
bits: 64,
},
"ASSETS/Custom/One.tex\n",
)
.unwrap()
});
let plan = modpkg.extraction_plan();
assert!(paths(&plan).contains(&"hashes/game.hashes.txt".to_string()));
assert!(paths(&plan.root_files()).is_empty());
}
#[test]
fn a_hashtable_tail_that_escapes_lands_by_its_file_name() {
assert_eq!(
hashtable_file_name("_meta_/hashes/game.hashes.txt"),
Some("game.hashes.txt")
);
assert_eq!(
hashtable_file_name("_meta_/hashes/../license"),
Some("license")
);
assert_eq!(
hashtable_file_name("_meta_/hashes/sub/dir.txt"),
Some("dir.txt")
);
assert_eq!(
hashtable_file_name("_meta_/hashes/evil\\name.txt"),
Some("name.txt")
);
assert_eq!(hashtable_file_name("_meta_/hashes/"), None);
assert_eq!(hashtable_file_name("_meta_/hashes/x/.."), None);
assert_eq!(hashtable_file_name("_meta_/license"), None);
}
#[test]
fn a_plan_narrows_to_one_layer_and_to_the_root_files() {
let modpkg = three_layers_and_a_readme();
let plan = modpkg.extraction_plan();
assert_eq!(paths(&plan.layer("zed")), ["zed/x.bin"]);
assert_eq!(paths(&plan.root_files()), ["README.md"]);
}
#[test]
fn narrowing_to_a_layer_the_package_does_not_hold_plans_nothing() {
let modpkg = three_layers_and_a_readme();
assert!(modpkg.extraction_plan().layer("empty").chunks().is_empty());
}
#[test]
fn a_shared_chunk_lands_under_every_wad_that_claims_it() {
let modpkg = package(|builder| {
builder
.with_chunk(chunk("data.bin").with_wad("a.wad.client"))
.with_chunk(chunk("data.bin").with_wad("b.wad.client"))
});
assert_eq!(
paths(&modpkg.extraction_plan()),
["base/a.wad.client/data.bin", "base/b.wad.client/data.bin"]
);
}
#[test]
fn the_metadata_chunk_is_not_planned() {
let modpkg = package(|builder| builder.with_chunk(chunk("x.bin")));
assert_eq!(paths(&modpkg.extraction_plan()), ["base/x.bin"]);
}
#[test]
fn a_plan_carries_the_chunk_it_is_for() {
let modpkg = package(|builder| builder.with_chunk(chunk("x.bin")));
let plan = modpkg.extraction_plan();
let bytes: u64 = plan
.chunks()
.iter()
.map(|planned| planned.chunk.uncompressed_size)
.sum();
assert_eq!(bytes, 10);
}
}