diff --git a/Cargo.lock b/Cargo.lock index 5cc5916..e2100d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -199,6 +199,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bcdec_rs" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9934c2b68e46448d814db20e34a840ef9b4e7b3b7c8b1da91161481230f6350" + [[package]] name = "binrw" version = "0.14.1" @@ -288,6 +294,20 @@ name = "bytemuck" version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] [[package]] name = "byteorder" @@ -326,6 +346,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "camino" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" + [[package]] name = "cc" version = "1.2.30" @@ -536,6 +562,18 @@ dependencies = [ "typenum", ] +[[package]] +name = "ddsfile" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479dfe1e6737aa9e96c6ac7b69689dc4c32da8383f2c12744739d76afa8b66c4" +dependencies = [ + "bitflags 2.9.1", + "byteorder", + "enum-primitive-derive", + "num-traits", +] + [[package]] name = "deflate64" version = "0.1.9" @@ -602,6 +640,17 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "enum-primitive-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c375b9c5eadb68d0a6efee2999fef292f45854c3444c86f09d8ab086ba942b0e" +dependencies = [ + "num-traits", + "quote", + "syn 1.0.109", +] + [[package]] name = "equator" version = "0.4.2" @@ -862,6 +911,7 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" dependencies = [ + "bytemuck", "cfg-if", "crunchy", ] @@ -878,6 +928,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hmac" version = "0.12.1" @@ -1130,6 +1186,20 @@ dependencies = [ "quick-error 2.0.1", ] +[[package]] +name = "image_dds" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c6d1a2d80bc7dd2928b2a72a46d71bccbb6becf8ce207522b0b92daf0a417f" +dependencies = [ + "bcdec_rs", + "bytemuck", + "ddsfile", + "half", + "image", + "thiserror 1.0.69", +] + [[package]] name = "imgref" version = "1.11.0" @@ -1178,6 +1248,15 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "intel_tex_2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7980d359170e7da4bc2421aa74c49d338a3153313e006d732a3430f1af375a60" +dependencies = [ + "ispc_rt", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -1228,6 +1307,16 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "ispc_rt" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "975b362d330dc07079e546ef0e7c6e7ab03f7fbcf251609d5d5dcd3caf398ed6" +dependencies = [ + "libc", + "num_cpus", +] + [[package]] name = "itertools" version = "0.12.1" @@ -1283,6 +1372,7 @@ name = "league-mod" version = "0.2.0" dependencies = [ "binrw", + "camino", "clap", "colored", "glob", @@ -1291,6 +1381,7 @@ dependencies = [ "ltk_fantome", "ltk_mod_project", "ltk_modpkg", + "ltk_texture", "miette", "regex", "reqwest", @@ -1371,6 +1462,7 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" name = "ltk_fantome" version = "0.1.1" dependencies = [ + "camino", "eyre", "image", "ltk_mod_project", @@ -1430,6 +1522,23 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ltk_texture" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a778561f06dbbdcc1cff2c5ef110fd9d83ce5c0c8183431473b70f279ed60c" +dependencies = [ + "bitflags 2.9.1", + "byteorder", + "ddsfile", + "image", + "image_dds", + "intel_tex_2", + "num_enum", + "texture2ddecoder", + "thiserror 1.0.69", +] + [[package]] name = "lzma-rs" version = "0.3.0" @@ -1633,6 +1742,38 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "object" version = "0.36.7" @@ -1766,6 +1907,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.5", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -2290,18 +2440,28 @@ checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -2558,6 +2718,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "texture2ddecoder" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427ae8ec7f2f0fdd3146b77cfa44bea880caf066f7e55398a8467afe2645c832" +dependencies = [ + "paste", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -2710,8 +2879,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -2723,6 +2892,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -2732,11 +2910,32 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", "winnow", ] +[[package]] +name = "toml_edit" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ad0b7ae9cfeef5605163839cb9221f453399f15cfb5c10be9885fcf56611f9" +dependencies = [ + "indexmap", + "toml_datetime 0.7.2", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" +dependencies = [ + "winnow", +] + [[package]] name = "toml_write" version = "0.1.2" diff --git a/Cargo.toml b/Cargo.toml index facafdf..cf5c669 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,3 +2,6 @@ resolver = "2" members = ["crates/*"] + +[workspace.dependencies] +camino = "1.1" diff --git a/crates/league-mod/Cargo.toml b/crates/league-mod/Cargo.toml index a76908f..21c0b80 100644 --- a/crates/league-mod/Cargo.toml +++ b/crates/league-mod/Cargo.toml @@ -25,6 +25,7 @@ inquire = "0.7.5" slug = "0.1.6" ltk_modpkg = { version = "0.1.1", path = "../ltk_modpkg" } ltk_fantome = { version = "0.1.1", path = "../ltk_fantome" } +ltk_texture = { version = "0.3.0" } glob = "0.3.2" semver = "1.0.25" binrw = "0.14.1" @@ -39,3 +40,4 @@ reqwest = { version = "0.12", default-features = false, features = [ "rustls-tls", ] } serde = { version = "1.0", features = ["derive"] } +camino = { workspace = true } diff --git a/crates/league-mod/src/commands/pack.rs b/crates/league-mod/src/commands/pack.rs index e5d35b0..2a7c090 100644 --- a/crates/league-mod/src/commands/pack.rs +++ b/crates/league-mod/src/commands/pack.rs @@ -1,8 +1,10 @@ use crate::println_pad; +use crate::transformers::{plan_transforms, TransformPlan}; use crate::{ errors::CliError, utils::{self, validate_mod_name, validate_version_format}, }; +use camino::{Utf8Path, Utf8PathBuf}; use colored::Colorize; use image::ImageFormat; use ltk_fantome::pack_to_fantome; @@ -13,7 +15,6 @@ use ltk_modpkg::{ ModpkgCompression, ModpkgMetadata, README_CHUNK_PATH, THUMBNAIL_CHUNK_PATH, }; use miette::{miette, IntoDiagnostic, Result, WrapErr}; -use std::ffi::OsStr; use std::fs; use std::io; use std::io::Cursor; @@ -21,7 +22,6 @@ use std::{ collections::HashMap, fs::File, io::{BufWriter, Read, Write}, - path::{Path, PathBuf}, }; #[derive(Debug, Clone, Copy, clap::ValueEnum)] @@ -53,11 +53,11 @@ pub fn pack_mod_project(args: PackModProjectArgs) -> Result<()> { fn pack_to_modpkg( args: PackModProjectArgs, - config_path: PathBuf, + config_path: Utf8PathBuf, mod_project: ModProject, ) -> Result<()> { let content_dir = resolve_content_dir(&config_path)?; - let project_root = config_path.parent().unwrap().to_path_buf(); + let project_root = config_path.parent().unwrap(); validate_layer_presence(&mod_project, config_path.parent().unwrap())?; @@ -70,7 +70,7 @@ fn pack_to_modpkg( let output_dir = resolve_output_dir(&args.output_dir, &config_path)?; if !output_dir.exists() { - println_pad!("Creating output directory: {}", output_dir.display()); + println_pad!("Creating output directory: {}", output_dir.as_str()); std::fs::create_dir_all(&output_dir).into_diagnostic()?; } @@ -78,14 +78,18 @@ fn pack_to_modpkg( let mut chunk_filepaths = HashMap::new(); modpkg_builder = build_metadata(modpkg_builder, &mod_project); + // Plan file transforms (barebones planner - no execution yet) + let transform_plan = plan_transforms(&mod_project, &content_dir); + modpkg_builder = build_layers( modpkg_builder, &content_dir, &mod_project, + &transform_plan, &mut chunk_filepaths, )?; - modpkg_builder = add_meta_chunks(modpkg_builder, &project_root, &mod_project)?; + modpkg_builder = add_meta_chunks(modpkg_builder, project_root, &mod_project)?; let modpkg_file_name = create_modpkg_file_name(&mod_project, args.file_name); let mut writer = @@ -96,7 +100,7 @@ fn pack_to_modpkg( write_chunk_payload( chunk_builder, cursor, - &project_root, + project_root, &mod_project, &chunk_filepaths, ) @@ -110,8 +114,7 @@ fn pack_to_modpkg( "Path:".bright_green(), output_dir .join(modpkg_file_name) - .display() - .to_string() + .as_str() .bright_white() .bold() ); @@ -121,7 +124,7 @@ fn pack_to_modpkg( fn add_meta_chunks( mut builder: ModpkgBuilder, - project_root: &Path, + project_root: &Utf8Path, mod_project: &ModProject, ) -> Result { // README.md as meta chunk (no layer/wad) - optional @@ -154,7 +157,10 @@ fn add_meta_chunks( Ok(builder) } -fn write_meta_chunk_readme(cursor: &mut Cursor>, project_root: &Path) -> io::Result<()> { +fn write_meta_chunk_readme( + cursor: &mut Cursor>, + project_root: &Utf8Path, +) -> io::Result<()> { let readme_path = project_root.join("README.md"); if readme_path.exists() { let data = fs::read(readme_path)?; @@ -165,7 +171,7 @@ fn write_meta_chunk_readme(cursor: &mut Cursor>, project_root: &Path) -> fn write_meta_chunk_thumbnail( cursor: &mut Cursor>, - project_root: &Path, + project_root: &Utf8Path, mod_project: &ModProject, ) -> io::Result<()> { // Use configured path if present; otherwise fall back to project_root/thumbnail.webp @@ -180,7 +186,6 @@ fn write_meta_chunk_thumbnail( let is_webp = thumbnail_path .extension() - .and_then(OsStr::to_str) .map(|ext| ext.eq_ignore_ascii_case("webp")) .unwrap_or(false); @@ -200,7 +205,7 @@ fn write_meta_chunk_thumbnail( fn pack_to_fantome_format( args: PackModProjectArgs, - config_path: PathBuf, + config_path: Utf8PathBuf, mod_project: ModProject, ) -> Result<()> { println_pad!( @@ -221,7 +226,7 @@ fn pack_to_fantome_format( println_pad!( "{} {}", "📁 Creating output directory:".bright_yellow(), - output_dir.display().to_string().bright_white().bold() + output_dir.as_str().bright_white().bold() ); std::fs::create_dir_all(&output_dir).into_diagnostic()?; } @@ -241,7 +246,7 @@ fn pack_to_fantome_format( .bright_green() .bold(), "Path:".bright_green(), - output_path.display().to_string().bright_white().bold() + output_path.as_str().bright_white().bold() ); Ok(()) @@ -288,17 +293,19 @@ fn warn_about_unsupported_layers(mod_project: &ModProject) { // Config utils -fn resolve_config_path(config_path: Option) -> Result { +fn resolve_config_path(config_path: Option) -> Result { match config_path { - Some(path) => Ok(PathBuf::from(path)), + Some(path) => Ok(Utf8PathBuf::from(path)), None => { let cwd = std::env::current_dir().into_diagnostic()?; - resolve_correct_config_extension(&cwd) + resolve_correct_config_extension( + Utf8Path::from_path(&cwd).expect("cwd must be valid UTF-8"), + ) } } } -fn resolve_correct_config_extension(project_dir: &Path) -> Result { +fn resolve_correct_config_extension(project_dir: &Utf8Path) -> Result { // JSON first, then TOML let config_extensions = ["json", "toml"]; @@ -312,34 +319,26 @@ fn resolve_correct_config_extension(project_dir: &Path) -> Result { Err(CliError::config_not_found(project_dir.to_owned()).into()) } -fn load_config(config_path: &Path) -> Result { +fn load_config(config_path: &Utf8Path) -> Result { let config_extension = config_path.extension().unwrap_or_default(); - match config_extension.to_str() { - Some("json") => { - let file = File::open(config_path).into_diagnostic().with_context(|| { - format!("Failed to open config file: {}", config_path.display()) - })?; + match config_extension { + "json" => { + let file = File::open(config_path) + .into_diagnostic() + .with_context(|| format!("Failed to open config file: {}", config_path.as_str()))?; serde_json::from_reader(file) .into_diagnostic() .with_context(|| { - format!( - "Failed to parse JSON config file: {}", - config_path.display() - ) + format!("Failed to parse JSON config file: {}", config_path.as_str()) }) } - Some("toml") => { + "toml" => { let content = std::fs::read_to_string(config_path) .into_diagnostic() - .with_context(|| { - format!("Failed to read config file: {}", config_path.display()) - })?; + .with_context(|| format!("Failed to read config file: {}", config_path.as_str()))?; toml::from_str(&content).into_diagnostic().with_context(|| { - format!( - "Failed to parse TOML config file: {}", - config_path.display() - ) + format!("Failed to parse TOML config file: {}", config_path.as_str()) }) } _ => Err(miette!( @@ -348,12 +347,12 @@ fn load_config(config_path: &Path) -> Result { } } -fn resolve_content_dir(config_path: &Path) -> Result { +fn resolve_content_dir(config_path: &Utf8Path) -> Result { Ok(config_path.parent().unwrap().join("content")) } -fn resolve_output_dir(output_dir: &str, config_path: &Path) -> Result { - let output_dir = PathBuf::from(output_dir); +fn resolve_output_dir(output_dir: &str, config_path: &Utf8Path) -> Result { + let output_dir = Utf8PathBuf::from(output_dir); let output_dir = match output_dir.is_absolute() { true => output_dir, false => config_path.parent().unwrap().join(output_dir), @@ -363,7 +362,7 @@ fn resolve_output_dir(output_dir: &str, config_path: &Path) -> Result { // Layer utils -fn validate_layer_presence(mod_project: &ModProject, mod_project_dir: &Path) -> Result<()> { +fn validate_layer_presence(mod_project: &ModProject, mod_project_dir: &Utf8Path) -> Result<()> { for layer in &mod_project.layers { if !utils::is_valid_slug(&layer.name) { return Err(CliError::invalid_layer_name(layer.name.clone(), None).into()); @@ -380,7 +379,7 @@ fn validate_layer_presence(mod_project: &ModProject, mod_project_dir: &Path) -> Ok(()) } -fn validate_layer_dir_presence(mod_project_dir: &Path, layer_name: &str) -> Result<()> { +fn validate_layer_dir_presence(mod_project_dir: &Utf8Path, layer_name: &str) -> Result<()> { let layer_dir = mod_project_dir.join("content").join(layer_name); if !layer_dir.exists() { return Err(CliError::layer_directory_missing(layer_name.to_string(), layer_dir).into()); @@ -407,15 +406,17 @@ fn build_metadata(builder: ModpkgBuilder, mod_project: &ModProject) -> ModpkgBui fn build_layers( mut modpkg_builder: ModpkgBuilder, - content_dir: &Path, + content_dir: &Utf8Path, mod_project: &ModProject, - chunk_filepaths: &mut HashMap<(u64, u64), PathBuf>, + transform_plan: &TransformPlan, + chunk_filepaths: &mut HashMap<(u64, u64), Utf8PathBuf>, ) -> Result { // Process base layer modpkg_builder = build_layer_from_dir( modpkg_builder, content_dir, &ModProjectLayer::base(), + transform_plan, chunk_filepaths, )?; @@ -432,7 +433,13 @@ fn build_layers( ); modpkg_builder = modpkg_builder .with_layer(ModpkgLayerBuilder::new(layer.name.as_str()).with_priority(layer.priority)); - modpkg_builder = build_layer_from_dir(modpkg_builder, content_dir, layer, chunk_filepaths)?; + modpkg_builder = build_layer_from_dir( + modpkg_builder, + content_dir, + layer, + transform_plan, + chunk_filepaths, + )?; } Ok(modpkg_builder) @@ -440,43 +447,71 @@ fn build_layers( fn build_layer_from_dir( mut modpkg_builder: ModpkgBuilder, - content_dir: &Path, + content_dir: &Utf8Path, layer: &ModProjectLayer, - chunk_filepaths: &mut HashMap<(u64, u64), PathBuf>, + transform_plan: &TransformPlan, + chunk_filepaths: &mut HashMap<(u64, u64), Utf8PathBuf>, ) -> Result { let layer_dir = content_dir.join(layer.name.as_str()); - for entry in glob::glob(layer_dir.join("**/*").to_str().unwrap()) + for entry in glob::glob(layer_dir.join("**/*").as_str()) .into_diagnostic()? .filter_map(Result::ok) { + let entry = Utf8Path::from_path(&entry).expect("entry must be valid UTF-8"); + if !entry.is_file() { continue; } + // Skip files that are transformer inputs for this layer + if let Some(layer_plan) = transform_plan.get(layer.name.as_str()) { + if layer_plan.excluded_inputs.contains(entry) { + continue; + } + } + let layer_hash = hash_layer_name(layer.name.as_str()); let (modpkg_builder_new, path_hash) = - build_chunk_from_file(modpkg_builder, layer, &entry, &layer_dir)?; + build_chunk_from_file(modpkg_builder, layer, entry, &layer_dir)?; chunk_filepaths .entry((path_hash, layer_hash)) - .or_insert(entry); + .or_insert(entry.to_path_buf()); modpkg_builder = modpkg_builder_new; } + // Ensure expected outputs are included if they already exist on disk + if let Some(layer_plan) = transform_plan.get(layer.name.as_str()) { + for output_path in &layer_plan.expected_outputs { + if output_path.exists() && output_path.is_file() { + let (builder2, _hash) = + build_chunk_from_file(modpkg_builder, layer, output_path, &layer_dir)?; + modpkg_builder = builder2; + } else { + // Warn (non-fatal) for missing expected outputs + println_pad!( + "{} {}", + "⚠️ Missing expected transformed output:".bright_yellow(), + output_path.as_str().bright_white().bold() + ); + } + } + } + Ok(modpkg_builder) } fn build_chunk_from_file( modpkg_builder: ModpkgBuilder, layer: &ModProjectLayer, - file_path: &Path, - layer_dir: &Path, + file_path: &Utf8Path, + layer_dir: &Utf8Path, ) -> Result<(ModpkgBuilder, u64)> { let relative_path = file_path.strip_prefix(layer_dir).into_diagnostic()?; let chunk_builder = ModpkgChunkBuilder::new() - .with_path(relative_path.to_str().unwrap()) + .with_path(relative_path.as_str()) .into_diagnostic()? .with_compression(ModpkgCompression::Zstd) .with_layer(layer.name.as_str()); @@ -520,9 +555,9 @@ fn create_fantome_file_name(mod_project: &ModProject, custom_name: Option>, - project_root: &Path, + project_root: &Utf8Path, mod_project: &ModProject, - chunk_filepaths: &HashMap<(u64, u64), PathBuf>, + chunk_filepaths: &HashMap<(u64, u64), Utf8PathBuf>, ) -> io::Result<()> { // Handle meta chunks specially (no layer/wad) if chunk_builder.layer().is_empty() { diff --git a/crates/league-mod/src/errors.rs b/crates/league-mod/src/errors.rs index 1e84a5f..8824772 100644 --- a/crates/league-mod/src/errors.rs +++ b/crates/league-mod/src/errors.rs @@ -1,5 +1,5 @@ +use camino::Utf8PathBuf; use miette::{Diagnostic, SourceSpan}; -use std::path::PathBuf; use thiserror::Error; #[derive(Error, Debug, Diagnostic)] @@ -9,7 +9,7 @@ pub enum CliError { code(config::not_found), help("Create a mod.config.json or mod.config.toml file in your project directory") )] - ConfigNotFound { search_path: PathBuf }, + ConfigNotFound { search_path: Utf8PathBuf }, #[error("Invalid layer name: {name}")] #[diagnostic( @@ -29,7 +29,7 @@ pub enum CliError { )] LayerDirectoryMissing { layer_name: String, - expected_path: PathBuf, + expected_path: Utf8PathBuf, }, #[error("Invalid mod name: {name}")] @@ -71,7 +71,7 @@ pub enum CliError { code(file::not_found), help("Make sure the file exists and the path is correct") )] - FileNotFound { path: PathBuf }, + FileNotFound { path: Utf8PathBuf }, #[error("Directory creation failed")] #[diagnostic( @@ -79,7 +79,7 @@ pub enum CliError { help("Check file permissions and available disk space") )] DirectoryCreationFailed { - path: PathBuf, + path: Utf8PathBuf, #[source] source: std::io::Error, }, @@ -100,7 +100,7 @@ pub enum CliError { } impl CliError { - pub fn config_not_found(search_path: PathBuf) -> Self { + pub fn config_not_found(search_path: Utf8PathBuf) -> Self { Self::ConfigNotFound { search_path } } @@ -108,7 +108,7 @@ impl CliError { Self::InvalidLayerName { name, span } } - pub fn layer_directory_missing(layer_name: String, expected_path: PathBuf) -> Self { + pub fn layer_directory_missing(layer_name: String, expected_path: Utf8PathBuf) -> Self { Self::LayerDirectoryMissing { layer_name, expected_path, @@ -132,12 +132,12 @@ impl CliError { } #[allow(unused)] - pub fn file_not_found(path: PathBuf) -> Self { + pub fn file_not_found(path: Utf8PathBuf) -> Self { Self::FileNotFound { path } } #[allow(unused)] - pub fn directory_creation_failed(path: PathBuf, source: std::io::Error) -> Self { + pub fn directory_creation_failed(path: Utf8PathBuf, source: std::io::Error) -> Self { Self::DirectoryCreationFailed { path, source } } diff --git a/crates/league-mod/src/main.rs b/crates/league-mod/src/main.rs index 0428fc4..d583374 100644 --- a/crates/league-mod/src/main.rs +++ b/crates/league-mod/src/main.rs @@ -9,6 +9,7 @@ use miette::Result; mod commands; mod errors; +mod transformers; mod utils; #[derive(Parser, Debug)] diff --git a/crates/league-mod/src/transformers/mod.rs b/crates/league-mod/src/transformers/mod.rs new file mode 100644 index 0000000..5dc20b3 --- /dev/null +++ b/crates/league-mod/src/transformers/mod.rs @@ -0,0 +1,91 @@ +use camino::{Utf8Path, Utf8PathBuf}; +use glob::Pattern; +use ltk_mod_project::{FileTransformer, ModProject}; +use std::collections::{HashMap, HashSet}; + +/// Planned transforms for a single layer. +#[derive(Debug, Default, Clone)] +pub struct LayerTransformPlan { + /// Files that should be excluded from packing because they are inputs to a transformer. + pub excluded_inputs: HashSet, + /// Files that should be additionally included (expected outputs) produced by transformers. + /// For now we only plan expected outputs; generation is handled elsewhere in the future. + pub expected_outputs: HashSet, +} + +/// Full plan across layers: layer_name -> plan +pub type TransformPlan = HashMap; + +/// Compute which files should be excluded or included based on configured transformers. +/// This is a minimal, non-executing planning stage. +pub fn plan_transforms(mod_project: &ModProject, content_dir: &Utf8Path) -> TransformPlan { + let mut plan: TransformPlan = HashMap::new(); + + if mod_project.transformers.is_empty() { + return plan; + } + + // Pre-compile glob patterns per transformer + let compiled: Vec<(String, Vec)> = mod_project + .transformers + .iter() + .map(|t: &FileTransformer| { + let pats = t + .patterns + .iter() + .filter_map(|p| Pattern::new(p).ok()) + .collect::>(); + (t.name.clone(), pats) + }) + .collect(); + + // For each layer, walk files and mark matches + for layer in &mod_project.layers { + let layer_dir = content_dir.join(layer.name.as_str()); + let mut layer_plan = LayerTransformPlan::default(); + + // Gather files with a naive glob walk + if let Ok(paths) = glob::glob(layer_dir.join("**/*").as_str()) { + for entry in paths.flatten() { + if !entry.is_file() { + continue; + } + + let entry = Utf8Path::from_path(&entry).expect("entry must be valid UTF-8"); + + // Get relative path within layer for pattern matching + let rel = match entry.strip_prefix(&layer_dir) { + Ok(r) => r, + Err(_) => continue, + }; + // Check each transformer + for (name, patterns) in &compiled { + if patterns.iter().any(|p| p.matches(rel.as_str())) { + // Currently we only have awareness of "tex-converter" + if name == "tex-converter" { + layer_plan.excluded_inputs.insert(entry.to_path_buf()); + + // Compute expected output path by swapping extension to .tex + let mut out_rel = rel.to_path_buf(); + out_rel.set_extension("tex"); + let out_abs = layer_dir.join(out_rel); + layer_plan.expected_outputs.insert(out_abs); + } + } + } + } + } + + plan.insert(layer.name.clone(), layer_plan); + } + + // Ensure base layer exists in plan as well, even if not explicitly in config + if !plan.contains_key("base") { + let layer_dir = content_dir.join("base"); + if layer_dir.exists() { + plan.insert("base".to_string(), LayerTransformPlan::default()); + } + } + + plan +} diff --git a/crates/ltk_fantome/Cargo.toml b/crates/ltk_fantome/Cargo.toml index 26570e4..4f41b25 100644 --- a/crates/ltk_fantome/Cargo.toml +++ b/crates/ltk_fantome/Cargo.toml @@ -20,3 +20,4 @@ serde_json = "1.0" eyre = "0.6" walkdir = "2.5.0" ltk_mod_project = { version = "0.1.1", path = "../ltk_mod_project" } +camino = { workspace = true } diff --git a/crates/ltk_fantome/src/lib.rs b/crates/ltk_fantome/src/lib.rs index f30887f..5769c9a 100644 --- a/crates/ltk_fantome/src/lib.rs +++ b/crates/ltk_fantome/src/lib.rs @@ -1,10 +1,10 @@ +use camino::Utf8Path; use eyre::Result; use image::ImageFormat; use ltk_mod_project::{ModProject, ModProjectAuthor}; use serde::{Deserialize, Serialize}; use std::fs::{File, read_dir}; use std::io::Write; -use std::path::Path; use zip::{ZipWriter, write::SimpleFileOptions}; /// Fantome metadata structure that goes into info.json @@ -24,7 +24,7 @@ pub struct FantomeInfo { pub fn pack_to_fantome( writer: W, mod_project: &ModProject, - project_root: &Path, + project_root: &Utf8Path, ) -> Result<()> { let mut zip = ZipWriter::new(writer); let options = SimpleFileOptions::default() @@ -43,7 +43,7 @@ pub fn pack_to_fantome( fn pack_base_layer( zip: &mut ZipWriter, - project_root: &Path, + project_root: &Utf8Path, options: &SimpleFileOptions, ) -> Result<()> { let base_layer_path = project_root.join("content").join("base"); @@ -51,24 +51,18 @@ fn pack_base_layer( if !base_layer_path.exists() { return Err(eyre::eyre!( "Base layer directory does not exist: {}", - base_layer_path.display() + base_layer_path.as_str() )); } // Iterate through all .wad.client directories in the base layer - for entry in read_dir(&base_layer_path)? { - let entry = entry?; + for entry in read_dir(&base_layer_path)?.flatten() { let path = entry.path(); + let path = Utf8Path::from_path(&path).expect("path must be valid UTF-8"); + let file_name = path.file_name().expect("File must have a name"); - if path.is_dir() - && path - .file_name() - .unwrap() - .to_string_lossy() - .ends_with(".wad.client") - { - let wad_name = path.file_name().unwrap().to_string_lossy(); - pack_wad_directory(zip, &path, &format!("WAD/{}", wad_name), options)?; + if path.is_dir() && file_name.ends_with(".wad.client") { + pack_wad_directory(zip, path, &format!("WAD/{}", file_name), options)?; } } @@ -77,7 +71,7 @@ fn pack_base_layer( fn pack_wad_directory( zip: &mut ZipWriter, - wad_dir: &Path, + wad_dir: &Utf8Path, zip_prefix: &str, options: &SimpleFileOptions, ) -> Result<()> { @@ -105,7 +99,7 @@ fn pack_wad_directory( fn pack_metadata( zip: &mut ZipWriter, mod_project: &ModProject, - project_root: &Path, + project_root: &Utf8Path, options: &SimpleFileOptions, ) -> Result<()> { // Create info.json @@ -140,7 +134,7 @@ fn pack_metadata( fn pack_image( zip: &mut ZipWriter, - image_path: &Path, + image_path: &Utf8Path, options: &SimpleFileOptions, ) -> Result<()> { let img = image::open(image_path)?;