diff --git a/Cargo.lock b/Cargo.lock index 63271bf..0ae9f57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,6 +55,15 @@ dependencies = [ "equator", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -425,6 +434,20 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" @@ -799,7 +822,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -905,6 +928,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1197,6 +1230,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1683,6 +1740,28 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ltk_mod_lib" +version = "0.1.0" +dependencies = [ + "camino", + "chrono", + "fs2", + "ltk_fantome", + "ltk_mod_core", + "ltk_mod_project", + "ltk_modpkg", + "ltk_overlay", + "serde", + "serde_json", + "slug", + "tempfile", + "thiserror 2.0.18", + "tracing", + "uuid", + "zip", +] + [[package]] name = "ltk_mod_project" version = "0.3.0" @@ -1969,7 +2048,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2433,7 +2512,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2768,7 +2847,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3196,7 +3275,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3632,6 +3711,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "v_frame" version = "0.3.9" @@ -3883,7 +3973,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2735957..e752351 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/ltk_mod_project", "crates/ltk_overlay", "crates/ltk_pki", + "crates/ltk_mod_lib", ] [workspace.dependencies] diff --git a/crates/ltk_mod_lib/Cargo.toml b/crates/ltk_mod_lib/Cargo.toml new file mode 100644 index 0000000..1c5428f --- /dev/null +++ b/crates/ltk_mod_lib/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "ltk_mod_lib" +version = "0.1.0" +edition = "2021" +description = "Mod library management for LeagueToolkit — install, profiles, overlay orchestration" +license = "MIT OR Apache-2.0" +repository = "https://github.com/LeagueToolkit/league-mod" +homepage = "https://github.com/LeagueToolkit/league-mod" +readme = "../../README.md" +keywords = ["league-of-legends", "modding", "gaming", "toolkit"] +categories = ["game-development"] +authors = ["LeagueToolkit"] + +[dependencies] +ltk_overlay = { version = "0.2.3", path = "../ltk_overlay" } +ltk_modpkg = { version = "0.3.2", path = "../ltk_modpkg", features = ["project"] } +ltk_mod_project = { version = "0.3.0", path = "../ltk_mod_project" } +ltk_fantome = { version = "0.4.0", path = "../ltk_fantome" } +ltk_mod_core = { version = "0.1.0", path = "../ltk_mod_core" } + +camino = { workspace = true } + +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1", features = ["v4"] } +slug = "0.1" +zip = "2" +tracing = "0.1" +fs2 = "0.4" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/ltk_mod_lib/src/error.rs b/crates/ltk_mod_lib/src/error.rs new file mode 100644 index 0000000..7d195da --- /dev/null +++ b/crates/ltk_mod_lib/src/error.rs @@ -0,0 +1,41 @@ +/// Library-specific error types with no Tauri dependency. +#[derive(Debug, thiserror::Error)] +pub enum LibraryError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Modpkg error: {0}")] + Modpkg(#[from] ltk_modpkg::error::ModpkgError), + + #[error("Fantome error: {0}")] + Fantome(String), + + #[error("Mod not found: {0}")] + ModNotFound(String), + + #[error("Invalid path: {0}")] + InvalidPath(String), + + #[error("Validation failed: {0}")] + ValidationFailed(String), + + #[error("Storage locked by another process")] + StorageLocked, + + #[error("Library index is corrupt: {0}")] + IndexCorrupt(String), + + #[error("ZIP error: {0}")] + Zip(#[from] zip::result::ZipError), + + #[error("Overlay build failed: {0}")] + OverlayFailed(String), + + #[error("{0}")] + Other(String), +} + +pub type LibraryResult = Result; diff --git a/crates/ltk_mod_lib/src/index.rs b/crates/ltk_mod_lib/src/index.rs new file mode 100644 index 0000000..d8084e9 --- /dev/null +++ b/crates/ltk_mod_lib/src/index.rs @@ -0,0 +1,1167 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; + +use camino::{Utf8Path, Utf8PathBuf}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::{LibraryError, LibraryResult}; +use crate::profile::{Profile, ProfileSlug}; +use crate::progress::ProgressReporter; + +/// Root persistent data structure, serialized to `library.json`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LibraryIndex { + pub mods: Vec, + pub profiles: Vec, + pub active_profile_id: String, +} + +impl Default for LibraryIndex { + fn default() -> Self { + let default_profile = Profile { + id: Uuid::new_v4().to_string(), + name: "Default".to_string(), + slug: ProfileSlug::from("default".to_string()), + enabled_mods: Vec::new(), + mod_order: Vec::new(), + layer_states: HashMap::new(), + created_at: Utc::now(), + last_used: Utc::now(), + }; + let active_profile_id = default_profile.id.clone(); + + Self { + mods: Vec::new(), + profiles: vec![default_profile], + active_profile_id, + } + } +} + +// --------------------------------------------------------------------------- +// Mod management +// --------------------------------------------------------------------------- + +impl LibraryIndex { + // ----------------------------------------------------------------------- + // Persistence + // ----------------------------------------------------------------------- + + /// Load the library index from disk. Returns a default index if the file doesn't exist. + pub fn load(storage_dir: &Utf8Path) -> LibraryResult { + fs::create_dir_all(storage_dir.as_std_path())?; + let path = storage_dir.join("library.json"); + if !path.exists() { + return Ok(Self::default()); + } + let content = fs::read_to_string(path.as_std_path())?; + let index: Self = serde_json::from_str(&content)?; + Ok(index) + } + + /// Save the library index to disk. + pub fn save(&self, storage_dir: &Utf8Path) -> LibraryResult<()> { + fs::create_dir_all(storage_dir.as_std_path())?; + let path = storage_dir.join("library.json"); + let contents = serde_json::to_string_pretty(self)?; + fs::write(path.as_std_path(), contents)?; + Ok(()) + } + + /// Uninstall a mod by ID. Removes files from storage and updates the index. + pub fn uninstall_mod(&mut self, storage_dir: &Utf8Path, mod_id: &str) -> LibraryResult<()> { + let Some(pos) = self.mods.iter().position(|m| m.id == mod_id) else { + return Err(LibraryError::ModNotFound(mod_id.to_string())); + }; + + let entry = self.mods.remove(pos); + + for profile in &mut self.profiles { + profile.mod_order.retain(|id| id != mod_id); + profile.enabled_mods.retain(|id| id != mod_id); + profile.layer_states.remove(mod_id); + } + + let metadata_dir = entry.metadata_dir(storage_dir); + if metadata_dir.exists() { + fs::remove_dir_all(metadata_dir.as_std_path())?; + } + + let archive_path = entry.archive_path(storage_dir); + if archive_path.exists() { + fs::remove_file(archive_path.as_std_path())?; + } + + Ok(()) + } + + /// Enable or disable a mod in the active profile. + pub fn toggle_mod(&mut self, mod_id: &str, enabled: bool) -> LibraryResult<()> { + if !self.mods.iter().any(|m| m.id == mod_id) { + return Err(LibraryError::ModNotFound(mod_id.to_string())); + } + + let profile = self.active_profile_mut()?; + + if enabled { + if !profile.enabled_mods.contains(&mod_id.to_string()) { + let insert_pos = profile.insertion_position_for(mod_id); + profile.enabled_mods.insert(insert_pos, mod_id.to_string()); + } + } else { + profile.enabled_mods.retain(|id| id != mod_id); + } + + Ok(()) + } + + /// Reorder all mods for the active profile. + /// The provided `mod_ids` must exactly match the active profile's mod order. + pub fn reorder_mods(&mut self, mod_ids: Vec) -> LibraryResult<()> { + let profile = self.active_profile_mut()?; + + let mut expected_sorted: Vec<&str> = profile.mod_order.iter().map(|s| s.as_str()).collect(); + expected_sorted.sort(); + let mut new_sorted: Vec<&str> = mod_ids.iter().map(|s| s.as_str()).collect(); + new_sorted.sort(); + + if expected_sorted != new_sorted { + return Err(LibraryError::ValidationFailed( + "Provided mod IDs do not match the profile's mod order".to_string(), + )); + } + + let enabled_set: HashSet<&str> = profile.enabled_mods.iter().map(|s| s.as_str()).collect(); + profile.enabled_mods = mod_ids + .iter() + .filter(|id| enabled_set.contains(id.as_str())) + .cloned() + .collect(); + + profile.mod_order = mod_ids; + Ok(()) + } + + /// Set layer enabled/disabled states for a mod in the active profile. + pub fn set_layer_states( + &mut self, + mod_id: &str, + layer_states: HashMap, + ) -> LibraryResult<()> { + if !self.mods.iter().any(|m| m.id == mod_id) { + return Err(LibraryError::ModNotFound(mod_id.to_string())); + } + + let profile = self.active_profile_mut()?; + profile + .layer_states + .insert(mod_id.to_string(), layer_states); + Ok(()) + } + + // ----------------------------------------------------------------------- + // Overlay + // ----------------------------------------------------------------------- + + /// Build the overlay for the active profile. + /// + /// Returns the overlay root directory path on success. + pub fn build_overlay( + &self, + storage_dir: &Utf8Path, + config: &crate::overlay::OverlayConfig, + reporter: std::sync::Arc, + ) -> LibraryResult { + crate::overlay::build_overlay(storage_dir, self, config, reporter) + } + + // ----------------------------------------------------------------------- + // Profile management + // ----------------------------------------------------------------------- + + /// Create a new profile. + pub fn create_profile( + &mut self, + storage_dir: &Utf8Path, + name: String, + ) -> LibraryResult { + let name = name.trim().to_string(); + if name.is_empty() { + return Err(LibraryError::ValidationFailed( + "Profile name cannot be empty".to_string(), + )); + } + + if self.profiles.iter().any(|p| p.name == name) { + return Err(LibraryError::ValidationFailed(format!( + "Profile '{}' already exists", + name + ))); + } + + let slug = ProfileSlug::from_name(&name).ok_or_else(|| { + LibraryError::ValidationFailed( + "Profile name must contain at least one alphanumeric character".to_string(), + ) + })?; + if !slug.is_unique_in(self, None) { + return Err(LibraryError::ValidationFailed(format!( + "Profile '{}' already exists", + name + ))); + } + + let mod_order: Vec = self.mods.iter().map(|m| m.id.clone()).collect(); + + let profile = Profile { + id: Uuid::new_v4().to_string(), + name, + slug, + enabled_mods: Vec::new(), + mod_order, + layer_states: HashMap::new(), + created_at: Utc::now(), + last_used: Utc::now(), + }; + + let (overlay_dir, cache_dir) = resolve_profile_dirs(storage_dir, &profile.slug); + fs::create_dir_all(overlay_dir.as_std_path())?; + fs::create_dir_all(cache_dir.as_std_path())?; + + self.profiles.push(profile.clone()); + Ok(profile) + } + + /// Delete a profile by ID. + pub fn delete_profile( + &mut self, + storage_dir: &Utf8Path, + profile_id: &str, + ) -> LibraryResult<()> { + let profile = self.profile_by_id(profile_id)?; + + if profile.name == "Default" { + return Err(LibraryError::ValidationFailed( + "Cannot delete Default profile".to_string(), + )); + } + if profile_id == self.active_profile_id { + return Err(LibraryError::ValidationFailed( + "Cannot delete active profile. Switch to another profile first.".to_string(), + )); + } + + let slug = profile.slug.clone(); + self.profiles.retain(|p| p.id != profile_id); + + let profile_dir = storage_dir.join("profiles").join(slug.as_str()); + if profile_dir.exists() { + fs::remove_dir_all(profile_dir.as_std_path())?; + } + Ok(()) + } + + /// Switch to a different profile. + pub fn switch_profile(&mut self, profile_id: &str) -> LibraryResult { + self.profile_by_id(profile_id)?; + self.active_profile_id = profile_id.to_string(); + + let profile = self.profile_by_id_mut(profile_id)?; + profile.last_used = Utc::now(); + Ok(profile.clone()) + } + + /// Rename a profile. + pub fn rename_profile( + &mut self, + storage_dir: &Utf8Path, + profile_id: &str, + new_name: String, + ) -> LibraryResult { + let new_name = new_name.trim().to_string(); + if new_name.is_empty() { + return Err(LibraryError::ValidationFailed( + "Profile name cannot be empty".to_string(), + )); + } + + let new_slug = ProfileSlug::from_name(&new_name).ok_or_else(|| { + LibraryError::ValidationFailed( + "Profile name must contain at least one alphanumeric character".to_string(), + ) + })?; + + if self + .profiles + .iter() + .any(|p| p.id != profile_id && p.name == new_name) + { + return Err(LibraryError::ValidationFailed(format!( + "Profile '{}' already exists", + new_name + ))); + } + + if !new_slug.is_unique_in(self, Some(profile_id)) { + return Err(LibraryError::ValidationFailed(format!( + "Profile directory name '{}' conflicts with another profile", + new_slug + ))); + } + + let profile = self.profile_by_id_mut(profile_id)?; + + if profile.name == "Default" { + return Err(LibraryError::ValidationFailed( + "Cannot rename Default profile".to_string(), + )); + } + + if profile.slug != new_slug { + let old_dir = storage_dir.join("profiles").join(profile.slug.as_str()); + let new_dir = storage_dir.join("profiles").join(new_slug.as_str()); + if old_dir.exists() { + fs::rename(old_dir.as_std_path(), new_dir.as_std_path())?; + } + } + + profile.name = new_name; + profile.slug = new_slug; + Ok(profile.clone()) + } + + // ----------------------------------------------------------------------- + // Overlay + // ----------------------------------------------------------------------- + + /// Delete the active profile's `overlay.json` to force rebuild. + pub fn invalidate_overlay(&self, storage_dir: &Utf8Path) -> LibraryResult<()> { + let profile = self.active_profile()?; + let overlay_json = storage_dir + .join("profiles") + .join(profile.slug.as_str()) + .join("overlay.json"); + if overlay_json.exists() { + fs::remove_file(overlay_json.as_std_path())?; + } + Ok(()) + } + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + /// Get the active profile. + pub fn active_profile(&self) -> LibraryResult<&Profile> { + self.profiles + .iter() + .find(|p| p.id == self.active_profile_id) + .ok_or_else(|| LibraryError::IndexCorrupt("Active profile not found".to_string())) + } + + /// Get a mutable reference to the active profile. + pub fn active_profile_mut(&mut self) -> LibraryResult<&mut Profile> { + let id = self.active_profile_id.clone(); + self.profiles + .iter_mut() + .find(|p| p.id == id) + .ok_or_else(|| LibraryError::IndexCorrupt("Active profile not found".to_string())) + } + + /// Get a profile by ID. + pub fn profile_by_id(&self, id: &str) -> LibraryResult<&Profile> { + self.profiles + .iter() + .find(|p| p.id == id) + .ok_or_else(|| LibraryError::Other(format!("Profile not found: {}", id))) + } + + /// Get a mutable reference to a profile by ID. + pub fn profile_by_id_mut(&mut self, id: &str) -> LibraryResult<&mut Profile> { + self.profiles + .iter_mut() + .find(|p| p.id == id) + .ok_or_else(|| LibraryError::Other(format!("Profile not found: {}", id))) + } +} + +// --------------------------------------------------------------------------- +// LibraryModEntry +// --------------------------------------------------------------------------- + +/// Per-mod record in the library index. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LibraryModEntry { + pub id: String, + pub installed_at: DateTime, + pub format: ModArchiveFormat, +} + +impl LibraryModEntry { + /// Directory containing extracted metadata (mod.config.json, thumbnail, etc). + pub fn metadata_dir(&self, storage_dir: &Utf8Path) -> Utf8PathBuf { + storage_dir.join("mods").join(&self.id) + } + + /// Path to the stored mod archive file. + pub fn archive_path(&self, storage_dir: &Utf8Path) -> Utf8PathBuf { + storage_dir + .join("archives") + .join(format!("{}.{}", self.id, self.format.extension())) + } +} + +/// Supported mod archive formats. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ModArchiveFormat { + Modpkg, + Fantome, +} + +impl ModArchiveFormat { + pub fn extension(self) -> &'static str { + match self { + ModArchiveFormat::Modpkg => "modpkg", + ModArchiveFormat::Fantome => "fantome", + } + } + + pub fn from_extension(ext: &str) -> Option { + match ext.to_ascii_lowercase().as_str() { + "modpkg" => Some(Self::Modpkg), + "fantome" => Some(Self::Fantome), + _ => None, + } + } +} + +/// Resolve profile overlay and cache directories. +pub fn resolve_profile_dirs( + storage_dir: &Utf8Path, + slug: &ProfileSlug, +) -> (Utf8PathBuf, Utf8PathBuf) { + let profile_dir = storage_dir.join("profiles").join(slug.as_str()); + (profile_dir.join("overlay"), profile_dir.join("cache")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn storage_dir() -> Utf8PathBuf { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().to_path_buf(); + #[allow(deprecated)] + let _ = dir.into_path(); + Utf8PathBuf::from_path_buf(path).unwrap() + } + + fn make_index_with_mods(mod_ids: &[&str]) -> LibraryIndex { + let mut index = LibraryIndex::default(); + for id in mod_ids { + index.mods.push(LibraryModEntry { + id: id.to_string(), + installed_at: Utc::now(), + format: ModArchiveFormat::Modpkg, + }); + } + let profile = index.active_profile_mut().unwrap(); + for id in mod_ids { + profile.mod_order.push(id.to_string()); + profile.enabled_mods.push(id.to_string()); + } + index + } + + // ----------------------------------------------------------------------- + // Default index + // ----------------------------------------------------------------------- + + #[test] + fn default_index_has_default_profile() { + let index = LibraryIndex::default(); + assert_eq!(index.profiles.len(), 1); + assert_eq!(index.profiles[0].name, "Default"); + assert_eq!(index.active_profile_id, index.profiles[0].id); + } + + #[test] + fn default_index_has_no_mods() { + let index = LibraryIndex::default(); + assert!(index.mods.is_empty()); + } + + // ----------------------------------------------------------------------- + // Persistence + // ----------------------------------------------------------------------- + + #[test] + fn load_returns_default_when_no_file() { + let dir = storage_dir(); + let index = LibraryIndex::load(&dir).unwrap(); + assert_eq!(index.profiles.len(), 1); + assert_eq!(index.profiles[0].name, "Default"); + } + + #[test] + fn save_and_load_roundtrip() { + let dir = storage_dir(); + let original = LibraryIndex::default(); + original.save(&dir).unwrap(); + + let loaded = LibraryIndex::load(&dir).unwrap(); + assert_eq!(loaded.profiles.len(), 1); + assert_eq!(loaded.profiles[0].name, "Default"); + assert_eq!(loaded.active_profile_id, original.active_profile_id); + } + + #[test] + fn save_creates_directory() { + let dir = storage_dir().join("deeply").join("nested"); + let index = LibraryIndex::default(); + index.save(&dir).unwrap(); + assert!(dir.join("library.json").exists()); + } + + #[test] + fn persistence_preserves_mods() { + let dir = storage_dir(); + let mut index = LibraryIndex::default(); + index.mods.push(LibraryModEntry { + id: "test-mod".to_string(), + installed_at: Utc::now(), + format: ModArchiveFormat::Fantome, + }); + index.save(&dir).unwrap(); + + let loaded = LibraryIndex::load(&dir).unwrap(); + assert_eq!(loaded.mods.len(), 1); + assert_eq!(loaded.mods[0].id, "test-mod"); + assert_eq!(loaded.mods[0].format, ModArchiveFormat::Fantome); + } + + // ----------------------------------------------------------------------- + // ModArchiveFormat + // ----------------------------------------------------------------------- + + #[test] + fn format_extension_roundtrip() { + assert_eq!(ModArchiveFormat::Modpkg.extension(), "modpkg"); + assert_eq!(ModArchiveFormat::Fantome.extension(), "fantome"); + + assert_eq!( + ModArchiveFormat::from_extension("modpkg"), + Some(ModArchiveFormat::Modpkg) + ); + assert_eq!( + ModArchiveFormat::from_extension("fantome"), + Some(ModArchiveFormat::Fantome) + ); + } + + #[test] + fn format_from_extension_case_insensitive() { + assert_eq!( + ModArchiveFormat::from_extension("MODPKG"), + Some(ModArchiveFormat::Modpkg) + ); + assert_eq!( + ModArchiveFormat::from_extension("Fantome"), + Some(ModArchiveFormat::Fantome) + ); + } + + #[test] + fn format_from_extension_unknown() { + assert_eq!(ModArchiveFormat::from_extension("zip"), None); + assert_eq!(ModArchiveFormat::from_extension(""), None); + } + + // ----------------------------------------------------------------------- + // LibraryModEntry paths + // ----------------------------------------------------------------------- + + #[test] + fn mod_entry_metadata_dir() { + let entry = LibraryModEntry { + id: "abc-123".to_string(), + installed_at: Utc::now(), + format: ModArchiveFormat::Modpkg, + }; + let dir = entry.metadata_dir(Utf8Path::new("/storage")); + assert_eq!(dir, Utf8PathBuf::from("/storage/mods/abc-123")); + } + + #[test] + fn mod_entry_archive_path_modpkg() { + let entry = LibraryModEntry { + id: "abc-123".to_string(), + installed_at: Utc::now(), + format: ModArchiveFormat::Modpkg, + }; + let path = entry.archive_path(Utf8Path::new("/storage")); + assert_eq!(path, Utf8PathBuf::from("/storage/archives/abc-123.modpkg")); + } + + #[test] + fn mod_entry_archive_path_fantome() { + let entry = LibraryModEntry { + id: "abc-123".to_string(), + installed_at: Utc::now(), + format: ModArchiveFormat::Fantome, + }; + let path = entry.archive_path(Utf8Path::new("/storage")); + assert_eq!(path, Utf8PathBuf::from("/storage/archives/abc-123.fantome")); + } + + // ----------------------------------------------------------------------- + // toggle_mod + // ----------------------------------------------------------------------- + + #[test] + fn toggle_mod_enable() { + let mut index = make_index_with_mods(&["a", "b", "c"]); + { + let profile = index.active_profile_mut().unwrap(); + profile.enabled_mods.clear(); + } + + index.toggle_mod("b", true).unwrap(); + let profile = index.active_profile().unwrap(); + assert_eq!(profile.enabled_mods, vec!["b"]); + } + + #[test] + fn toggle_mod_disable() { + let mut index = make_index_with_mods(&["a", "b", "c"]); + index.toggle_mod("b", false).unwrap(); + + let profile = index.active_profile().unwrap(); + assert_eq!(profile.enabled_mods, vec!["a", "c"]); + } + + #[test] + fn toggle_mod_enable_preserves_order() { + let mut index = make_index_with_mods(&["a", "b", "c"]); + { + let profile = index.active_profile_mut().unwrap(); + profile.enabled_mods = vec!["a".to_string(), "c".to_string()]; + } + + index.toggle_mod("b", true).unwrap(); + let profile = index.active_profile().unwrap(); + assert_eq!(profile.enabled_mods, vec!["a", "b", "c"]); + } + + #[test] + fn toggle_mod_enable_idempotent() { + let mut index = make_index_with_mods(&["a"]); + index.toggle_mod("a", true).unwrap(); + let profile = index.active_profile().unwrap(); + assert_eq!(profile.enabled_mods, vec!["a"]); + } + + #[test] + fn toggle_mod_not_found() { + let mut index = LibraryIndex::default(); + let result = index.toggle_mod("nonexistent", true); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), LibraryError::ModNotFound(_))); + } + + // ----------------------------------------------------------------------- + // reorder_mods + // ----------------------------------------------------------------------- + + #[test] + fn reorder_mods_changes_order() { + let mut index = make_index_with_mods(&["a", "b", "c"]); + index + .reorder_mods(vec!["c".into(), "a".into(), "b".into()]) + .unwrap(); + + let profile = index.active_profile().unwrap(); + assert_eq!(profile.mod_order, vec!["c", "a", "b"]); + } + + #[test] + fn reorder_mods_updates_enabled_order() { + let mut index = make_index_with_mods(&["a", "b", "c"]); + { + let profile = index.active_profile_mut().unwrap(); + profile.enabled_mods = vec!["a".to_string(), "c".to_string()]; + } + + index + .reorder_mods(vec!["c".into(), "b".into(), "a".into()]) + .unwrap(); + + let profile = index.active_profile().unwrap(); + assert_eq!(profile.enabled_mods, vec!["c", "a"]); + } + + #[test] + fn reorder_mods_rejects_mismatched_ids() { + let mut index = make_index_with_mods(&["a", "b"]); + let result = index.reorder_mods(vec!["a".into(), "x".into()]); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + LibraryError::ValidationFailed(_) + )); + } + + #[test] + fn reorder_mods_rejects_missing_ids() { + let mut index = make_index_with_mods(&["a", "b", "c"]); + let result = index.reorder_mods(vec!["a".into(), "b".into()]); + assert!(result.is_err()); + } + + // ----------------------------------------------------------------------- + // set_layer_states + // ----------------------------------------------------------------------- + + #[test] + fn set_layer_states_success() { + let mut index = make_index_with_mods(&["mod-1"]); + let mut states = HashMap::new(); + states.insert("base".to_string(), true); + states.insert("chroma".to_string(), false); + + index.set_layer_states("mod-1", states.clone()).unwrap(); + + let profile = index.active_profile().unwrap(); + assert_eq!(profile.layer_states.get("mod-1").unwrap(), &states); + } + + #[test] + fn set_layer_states_mod_not_found() { + let mut index = LibraryIndex::default(); + let result = index.set_layer_states("nope", HashMap::new()); + assert!(matches!(result.unwrap_err(), LibraryError::ModNotFound(_))); + } + + // ----------------------------------------------------------------------- + // uninstall_mod + // ----------------------------------------------------------------------- + + #[test] + fn uninstall_mod_removes_from_index() { + let dir = storage_dir(); + let mut index = make_index_with_mods(&["mod-1", "mod-2"]); + + // Create the expected filesystem entries so uninstall doesn't fail + let entry = index.mods.iter().find(|m| m.id == "mod-1").unwrap(); + let metadata_dir = entry.metadata_dir(&dir); + let archive_path = entry.archive_path(&dir); + fs::create_dir_all(metadata_dir.as_std_path()).unwrap(); + fs::create_dir_all(archive_path.parent().unwrap().as_std_path()).unwrap(); + fs::write(archive_path.as_std_path(), b"fake").unwrap(); + + index.uninstall_mod(&dir, "mod-1").unwrap(); + assert_eq!(index.mods.len(), 1); + assert_eq!(index.mods[0].id, "mod-2"); + } + + #[test] + fn uninstall_mod_removes_from_all_profiles() { + let dir = storage_dir(); + let mut index = make_index_with_mods(&["mod-1"]); + + // Add a second profile + let second = Profile { + id: "p2".to_string(), + name: "Second".to_string(), + slug: ProfileSlug::from("second".to_string()), + enabled_mods: vec!["mod-1".to_string()], + mod_order: vec!["mod-1".to_string()], + layer_states: { + let mut m = HashMap::new(); + m.insert("mod-1".to_string(), HashMap::new()); + m + }, + created_at: Utc::now(), + last_used: Utc::now(), + }; + index.profiles.push(second); + + // Create filesystem entries + let entry = index.mods.iter().find(|m| m.id == "mod-1").unwrap(); + let metadata_dir = entry.metadata_dir(&dir); + let archive_path = entry.archive_path(&dir); + fs::create_dir_all(metadata_dir.as_std_path()).unwrap(); + fs::create_dir_all(archive_path.parent().unwrap().as_std_path()).unwrap(); + fs::write(archive_path.as_std_path(), b"fake").unwrap(); + + index.uninstall_mod(&dir, "mod-1").unwrap(); + + for profile in &index.profiles { + assert!(!profile.enabled_mods.contains(&"mod-1".to_string())); + assert!(!profile.mod_order.contains(&"mod-1".to_string())); + assert!(!profile.layer_states.contains_key("mod-1")); + } + } + + #[test] + fn uninstall_mod_cleans_filesystem() { + let dir = storage_dir(); + let mut index = make_index_with_mods(&["mod-1"]); + + let entry = index.mods.iter().find(|m| m.id == "mod-1").unwrap(); + let metadata_dir = entry.metadata_dir(&dir); + let archive_path = entry.archive_path(&dir); + fs::create_dir_all(metadata_dir.as_std_path()).unwrap(); + fs::create_dir_all(archive_path.parent().unwrap().as_std_path()).unwrap(); + fs::write(metadata_dir.join("mod.config.json").as_std_path(), b"{}").unwrap(); + fs::write(archive_path.as_std_path(), b"fake").unwrap(); + + index.uninstall_mod(&dir, "mod-1").unwrap(); + assert!(!metadata_dir.exists()); + assert!(!archive_path.exists()); + } + + #[test] + fn uninstall_mod_not_found() { + let dir = storage_dir(); + let mut index = LibraryIndex::default(); + let result = index.uninstall_mod(&dir, "nonexistent"); + assert!(matches!(result.unwrap_err(), LibraryError::ModNotFound(_))); + } + + // ----------------------------------------------------------------------- + // Profile management + // ----------------------------------------------------------------------- + + #[test] + fn create_profile_success() { + let dir = storage_dir(); + let mut index = make_index_with_mods(&["mod-1", "mod-2"]); + + let profile = index.create_profile(&dir, "Ranked".to_string()).unwrap(); + + assert_eq!(profile.name, "Ranked"); + assert_eq!(profile.slug.as_str(), "ranked"); + assert!(profile.enabled_mods.is_empty()); + assert_eq!(profile.mod_order, vec!["mod-1", "mod-2"]); + assert_eq!(index.profiles.len(), 2); + } + + #[test] + fn create_profile_creates_directories() { + let dir = storage_dir(); + let mut index = LibraryIndex::default(); + let profile = index + .create_profile(&dir, "Test Profile".to_string()) + .unwrap(); + + let (overlay_dir, cache_dir) = resolve_profile_dirs(&dir, &profile.slug); + assert!(overlay_dir.exists()); + assert!(cache_dir.exists()); + } + + #[test] + fn create_profile_empty_name_fails() { + let dir = storage_dir(); + let mut index = LibraryIndex::default(); + let result = index.create_profile(&dir, "".to_string()); + assert!(matches!( + result.unwrap_err(), + LibraryError::ValidationFailed(_) + )); + } + + #[test] + fn create_profile_whitespace_name_fails() { + let dir = storage_dir(); + let mut index = LibraryIndex::default(); + let result = index.create_profile(&dir, " ".to_string()); + assert!(matches!( + result.unwrap_err(), + LibraryError::ValidationFailed(_) + )); + } + + #[test] + fn create_profile_duplicate_name_fails() { + let dir = storage_dir(); + let mut index = LibraryIndex::default(); + index.create_profile(&dir, "Ranked".to_string()).unwrap(); + let result = index.create_profile(&dir, "Ranked".to_string()); + assert!(matches!( + result.unwrap_err(), + LibraryError::ValidationFailed(_) + )); + } + + #[test] + fn create_profile_duplicate_slug_fails() { + let dir = storage_dir(); + let mut index = LibraryIndex::default(); + index + .create_profile(&dir, "My Profile".to_string()) + .unwrap(); + // "My Profile!" would produce the same slug "my-profile" + let result = index.create_profile(&dir, "My Profile!".to_string()); + assert!(result.is_err()); + } + + #[test] + fn delete_profile_success() { + let dir = storage_dir(); + let mut index = LibraryIndex::default(); + let profile = index.create_profile(&dir, "Ranked".to_string()).unwrap(); + + index.delete_profile(&dir, &profile.id).unwrap(); + assert_eq!(index.profiles.len(), 1); + assert_eq!(index.profiles[0].name, "Default"); + } + + #[test] + fn delete_default_profile_fails() { + let dir = storage_dir(); + let mut index = LibraryIndex::default(); + let default_id = index.profiles[0].id.clone(); + let result = index.delete_profile(&dir, &default_id); + assert!(matches!( + result.unwrap_err(), + LibraryError::ValidationFailed(_) + )); + } + + #[test] + fn delete_active_profile_fails() { + let dir = storage_dir(); + let mut index = LibraryIndex::default(); + let ranked = index.create_profile(&dir, "Ranked".to_string()).unwrap(); + index.switch_profile(&ranked.id).unwrap(); + + let result = index.delete_profile(&dir, &ranked.id); + assert!(matches!( + result.unwrap_err(), + LibraryError::ValidationFailed(_) + )); + } + + #[test] + fn delete_profile_cleans_filesystem() { + let dir = storage_dir(); + let mut index = LibraryIndex::default(); + let profile = index.create_profile(&dir, "Ranked".to_string()).unwrap(); + let profile_dir = dir.join("profiles").join(profile.slug.as_str()); + assert!(profile_dir.exists()); + + index.delete_profile(&dir, &profile.id).unwrap(); + assert!(!profile_dir.exists()); + } + + #[test] + fn switch_profile_success() { + let dir = storage_dir(); + let mut index = LibraryIndex::default(); + let ranked = index.create_profile(&dir, "Ranked".to_string()).unwrap(); + + let switched = index.switch_profile(&ranked.id).unwrap(); + assert_eq!(switched.name, "Ranked"); + assert_eq!(index.active_profile_id, ranked.id); + } + + #[test] + fn switch_profile_updates_last_used() { + let dir = storage_dir(); + let mut index = LibraryIndex::default(); + let ranked = index.create_profile(&dir, "Ranked".to_string()).unwrap(); + let original_last_used = ranked.last_used; + + std::thread::sleep(std::time::Duration::from_millis(10)); + let switched = index.switch_profile(&ranked.id).unwrap(); + assert!(switched.last_used >= original_last_used); + } + + #[test] + fn switch_profile_not_found() { + let mut index = LibraryIndex::default(); + let result = index.switch_profile("nonexistent"); + assert!(result.is_err()); + } + + #[test] + fn rename_profile_success() { + let dir = storage_dir(); + let mut index = LibraryIndex::default(); + let profile = index.create_profile(&dir, "Ranked".to_string()).unwrap(); + + let renamed = index + .rename_profile(&dir, &profile.id, "Competitive".to_string()) + .unwrap(); + + assert_eq!(renamed.name, "Competitive"); + assert_eq!(renamed.slug.as_str(), "competitive"); + } + + #[test] + fn rename_profile_moves_directory() { + let dir = storage_dir(); + let mut index = LibraryIndex::default(); + let profile = index.create_profile(&dir, "Ranked".to_string()).unwrap(); + let old_dir = dir.join("profiles").join("ranked"); + assert!(old_dir.exists()); + + index + .rename_profile(&dir, &profile.id, "Competitive".to_string()) + .unwrap(); + + assert!(!old_dir.exists()); + assert!(dir.join("profiles").join("competitive").exists()); + } + + #[test] + fn rename_default_profile_fails() { + let dir = storage_dir(); + let mut index = LibraryIndex::default(); + let default_id = index.profiles[0].id.clone(); + let result = index.rename_profile(&dir, &default_id, "Custom".to_string()); + assert!(matches!( + result.unwrap_err(), + LibraryError::ValidationFailed(_) + )); + } + + #[test] + fn rename_profile_empty_name_fails() { + let dir = storage_dir(); + let mut index = LibraryIndex::default(); + let profile = index.create_profile(&dir, "Ranked".to_string()).unwrap(); + let result = index.rename_profile(&dir, &profile.id, "".to_string()); + assert!(matches!( + result.unwrap_err(), + LibraryError::ValidationFailed(_) + )); + } + + #[test] + fn rename_profile_duplicate_name_fails() { + let dir = storage_dir(); + let mut index = LibraryIndex::default(); + index.create_profile(&dir, "Ranked".to_string()).unwrap(); + let second = index.create_profile(&dir, "Casual".to_string()).unwrap(); + let result = index.rename_profile(&dir, &second.id, "Ranked".to_string()); + assert!(matches!( + result.unwrap_err(), + LibraryError::ValidationFailed(_) + )); + } + + // ----------------------------------------------------------------------- + // Profile lookup helpers + // ----------------------------------------------------------------------- + + #[test] + fn active_profile_found() { + let index = LibraryIndex::default(); + let profile = index.active_profile().unwrap(); + assert_eq!(profile.name, "Default"); + } + + #[test] + fn active_profile_corrupt_index() { + let index = LibraryIndex { + mods: Vec::new(), + profiles: Vec::new(), + active_profile_id: "nonexistent".to_string(), + }; + assert!(matches!( + index.active_profile().unwrap_err(), + LibraryError::IndexCorrupt(_) + )); + } + + #[test] + fn profile_by_id_found() { + let index = LibraryIndex::default(); + let id = index.profiles[0].id.clone(); + let profile = index.profile_by_id(&id).unwrap(); + assert_eq!(profile.name, "Default"); + } + + #[test] + fn profile_by_id_not_found() { + let index = LibraryIndex::default(); + let result = index.profile_by_id("nope"); + assert!(result.is_err()); + } + + // ----------------------------------------------------------------------- + // invalidate_overlay + // ----------------------------------------------------------------------- + + #[test] + fn invalidate_overlay_removes_file() { + let dir = storage_dir(); + let index = LibraryIndex::default(); + let profile = index.active_profile().unwrap(); + let overlay_json = dir + .join("profiles") + .join(profile.slug.as_str()) + .join("overlay.json"); + + fs::create_dir_all(overlay_json.parent().unwrap().as_std_path()).unwrap(); + fs::write(overlay_json.as_std_path(), b"{}").unwrap(); + assert!(overlay_json.exists()); + + index.invalidate_overlay(&dir).unwrap(); + assert!(!overlay_json.exists()); + } + + #[test] + fn invalidate_overlay_noop_when_no_file() { + let dir = storage_dir(); + let index = LibraryIndex::default(); + // Need to create the profile dir so active_profile works + index.invalidate_overlay(&dir).unwrap(); + } + + // ----------------------------------------------------------------------- + // resolve_profile_dirs + // ----------------------------------------------------------------------- + + #[test] + fn resolve_profile_dirs_correct_paths() { + let slug = ProfileSlug::from("ranked".to_string()); + let (overlay, cache) = resolve_profile_dirs(Utf8Path::new("/storage"), &slug); + assert_eq!( + overlay, + Utf8PathBuf::from("/storage/profiles/ranked/overlay") + ); + assert_eq!(cache, Utf8PathBuf::from("/storage/profiles/ranked/cache")); + } + + // ----------------------------------------------------------------------- + // Serialization format + // ----------------------------------------------------------------------- + + #[test] + fn library_index_serializes_camel_case() { + let index = LibraryIndex::default(); + let json = serde_json::to_string(&index).unwrap(); + assert!(json.contains("activeProfileId")); + assert!(json.contains("enabledMods")); + assert!(json.contains("modOrder")); + assert!(json.contains("layerStates")); + assert!(json.contains("createdAt")); + assert!(json.contains("lastUsed")); + } + + #[test] + fn mod_archive_format_serializes_lowercase() { + let modpkg = serde_json::to_string(&ModArchiveFormat::Modpkg).unwrap(); + let fantome = serde_json::to_string(&ModArchiveFormat::Fantome).unwrap(); + assert_eq!(modpkg, "\"modpkg\""); + assert_eq!(fantome, "\"fantome\""); + } +} diff --git a/crates/ltk_mod_lib/src/install.rs b/crates/ltk_mod_lib/src/install.rs new file mode 100644 index 0000000..23e8018 --- /dev/null +++ b/crates/ltk_mod_lib/src/install.rs @@ -0,0 +1,338 @@ +use std::collections::{HashMap, HashSet}; +use std::fs::{self, File}; +use std::io::Read; + +use camino::{Utf8Path, Utf8PathBuf}; +use chrono::Utc; +use ltk_mod_project::{ModMap, ModProject, ModProjectAuthor, ModProjectLayer, ModTag}; +use ltk_modpkg::Modpkg; +use uuid::Uuid; +use zip::ZipArchive; + +use crate::error::{LibraryError, LibraryResult}; +use crate::index::{LibraryIndex, LibraryModEntry, ModArchiveFormat}; +use crate::progress::{InstallProgress, ProgressReporter}; +use crate::query::{InstalledMod, ModLayer}; + +/// Result of a bulk mod install operation. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BulkInstallResult { + pub installed: Vec, + pub failed: Vec, +} + +/// Error info for a single file that failed during bulk install. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BulkInstallError { + pub file_path: String, + pub file_name: String, + pub message: String, +} + +impl LibraryIndex { + /// Install a single mod from a package file. + pub fn install_mod( + &mut self, + storage_dir: &Utf8Path, + file_path: &Utf8Path, + ) -> LibraryResult { + let (_entry, installed_mod) = self.install_single_mod(storage_dir, file_path)?; + Ok(installed_mod) + } + + /// Install multiple mods in a batch operation. + pub fn install_mods_batch( + &mut self, + storage_dir: &Utf8Path, + file_paths: &[Utf8PathBuf], + reporter: &dyn ProgressReporter, + ) -> LibraryResult { + if file_paths.is_empty() { + return Ok(BulkInstallResult { + installed: Vec::new(), + failed: Vec::new(), + }); + } + + let total = file_paths.len(); + let mut installed = Vec::new(); + let mut failed = Vec::new(); + + for (i, file_path) in file_paths.iter().enumerate() { + let file_name = file_path + .file_name() + .unwrap_or(file_path.as_str()) + .to_string(); + + reporter.on_install_progress(InstallProgress { + current: i + 1, + total, + current_file: file_name.clone(), + }); + + match self.install_single_mod(storage_dir, file_path) { + Ok((_entry, mod_info)) => installed.push(mod_info), + Err(e) => { + tracing::warn!("Failed to install {}: {}", file_path, e); + failed.push(BulkInstallError { + file_path: file_path.to_string(), + file_name, + message: e.to_string(), + }); + } + } + } + + Ok(BulkInstallResult { installed, failed }) + } + + fn install_single_mod( + &mut self, + storage_dir: &Utf8Path, + file_path: &Utf8Path, + ) -> LibraryResult<(LibraryModEntry, InstalledMod)> { + if !file_path.exists() { + return Err(LibraryError::InvalidPath(file_path.to_string())); + } + + let archives_dir = storage_dir.join("archives"); + let metadata_dir = storage_dir.join("mods"); + fs::create_dir_all(archives_dir.as_std_path())?; + fs::create_dir_all(metadata_dir.as_std_path())?; + + let id = Uuid::new_v4().to_string(); + let format = file_path + .extension() + .and_then(ModArchiveFormat::from_extension) + .unwrap_or(ModArchiveFormat::Modpkg); + + let installed_at = Utc::now(); + + let archive_filename = format!("{}.{}", id, format.extension()); + let archive_path = archives_dir.join(&archive_filename); + fs::copy(file_path.as_std_path(), archive_path.as_std_path())?; + + let mod_metadata_dir = metadata_dir.join(&id); + fs::create_dir_all(mod_metadata_dir.as_std_path())?; + + match format { + ModArchiveFormat::Fantome => { + extract_fantome_metadata(&archive_path, &mod_metadata_dir)?; + } + ModArchiveFormat::Modpkg => { + extract_modpkg_metadata(&archive_path, &mod_metadata_dir)?; + } + } + + let entry = LibraryModEntry { + id: id.clone(), + installed_at, + format, + }; + self.mods.push(entry.clone()); + + if let Ok(profile) = self.active_profile_mut() { + profile.enabled_mods.insert(0, id.clone()); + profile.mod_order.insert(0, id.clone()); + } + + if let Ok(project) = load_mod_project(&mod_metadata_dir) { + let new_layer_names: HashSet<&str> = + project.layers.iter().map(|l| l.name.as_str()).collect(); + for profile in &mut self.profiles { + if let Some(states) = profile.layer_states.get_mut(&id) { + states.retain(|name, _| new_layer_names.contains(name.as_str())); + } + } + } + + let installed_mod = read_installed_mod(&entry, true, storage_dir, None)?; + Ok((entry, installed_mod)) + } +} + +pub(crate) fn load_mod_project(mod_dir: &Utf8Path) -> LibraryResult { + let config_path = mod_dir.join("mod.config.json"); + let contents = fs::read_to_string(config_path.as_std_path()).map_err(|e| { + LibraryError::Io(std::io::Error::new( + e.kind(), + format!("Failed to read {}: {}", config_path, e), + )) + })?; + serde_json::from_str(&contents).map_err(LibraryError::from) +} + +/// Read a single installed mod's metadata from disk and merge with profile state. +pub(crate) fn read_installed_mod( + entry: &LibraryModEntry, + enabled: bool, + storage_dir: &Utf8Path, + layer_states: Option<&HashMap>, +) -> LibraryResult { + let mod_dir = entry.metadata_dir(storage_dir); + let project = load_mod_project(&mod_dir)?; + + let authors = project + .authors + .iter() + .map(|a| match a { + ModProjectAuthor::Name(name) => name.clone(), + ModProjectAuthor::Role { name, role: _ } => name.clone(), + }) + .collect(); + + let layers = project + .layers + .iter() + .map(|l| ModLayer { + name: l.name.clone(), + priority: l.priority, + enabled: layer_states + .and_then(|states| states.get(&l.name)) + .copied() + .unwrap_or(true), + }) + .collect(); + + Ok(InstalledMod { + id: entry.id.clone(), + name: project.name, + display_name: project.display_name, + version: project.version, + description: Some(project.description).filter(|s| !s.is_empty()), + authors, + enabled, + installed_at: entry.installed_at, + layers, + tags: project.tags.iter().map(|t| t.to_string()).collect(), + champions: project.champions.clone(), + maps: project.maps.iter().map(|m| m.to_string()).collect(), + mod_dir: mod_dir.to_string(), + }) +} + +fn extract_modpkg_metadata(archive_path: &Utf8Path, metadata_dir: &Utf8Path) -> LibraryResult<()> { + let file = File::open(archive_path.as_std_path())?; + let mut modpkg = Modpkg::mount_from_reader(file)?; + + let metadata = modpkg.load_metadata()?; + + let mut layers: Vec = modpkg + .layers + .values() + .map(|l| { + let meta_layer = metadata.layers.iter().find(|ml| ml.name == l.name); + ModProjectLayer { + name: l.name.clone(), + priority: l.priority, + description: meta_layer.and_then(|ml| ml.description.clone()), + string_overrides: meta_layer + .map(|ml| ml.string_overrides.clone()) + .unwrap_or_default(), + } + }) + .collect(); + layers.sort_by(|a, b| a.priority.cmp(&b.priority).then(a.name.cmp(&b.name))); + + if !layers.iter().any(|l| l.name == "base") { + layers.insert(0, ModProjectLayer::base()); + } + + let project = ModProject { + name: metadata.name, + display_name: metadata.display_name, + version: metadata.version.to_string(), + description: metadata.description.unwrap_or_default(), + authors: metadata + .authors + .into_iter() + .map(|a| ModProjectAuthor::Name(a.name)) + .collect(), + license: None, + tags: metadata.tags.into_iter().map(ModTag::from).collect(), + champions: metadata.champions, + maps: metadata.maps.into_iter().map(ModMap::from).collect(), + transformers: Vec::new(), + layers, + thumbnail: None, + }; + + let config_json = serde_json::to_string_pretty(&project)?; + fs::write(metadata_dir.join("mod.config.json"), config_json)?; + + if let Ok(thumbnail_bytes) = modpkg.load_thumbnail() { + let _ = fs::write(metadata_dir.join("thumbnail.webp"), thumbnail_bytes); + } + + Ok(()) +} + +fn extract_fantome_metadata(file_path: &Utf8Path, metadata_dir: &Utf8Path) -> LibraryResult<()> { + let file = File::open(file_path.as_std_path())?; + let mut archive = ZipArchive::new(file) + .map_err(|e| LibraryError::Fantome(format!("Failed to open fantome archive: {}", e)))?; + + let mut info_content = String::new(); + let mut found_metadata = false; + + for i in 0..archive.len() { + let file = archive + .by_index(i) + .map_err(|e| LibraryError::Fantome(format!("Failed to read archive entry: {}", e)))?; + let name = file.name().to_lowercase(); + + if name == "meta/info.json" { + drop(file); + let mut info_file = archive + .by_index(i) + .map_err(|e| LibraryError::Fantome(format!("Failed to read info.json: {}", e)))?; + info_file.read_to_string(&mut info_content).map_err(|e| { + LibraryError::Fantome(format!("Failed to read info.json content: {}", e)) + })?; + found_metadata = true; + break; + } + } + + if !found_metadata { + return Err(LibraryError::Fantome( + "No META/info.json found in fantome archive".to_string(), + )); + } + + let info: serde_json::Value = serde_json::from_str(&info_content) + .map_err(|e| LibraryError::Fantome(format!("Invalid info.json: {}", e)))?; + + let name = info["Name"].as_str().unwrap_or("unknown").to_string(); + let display_name = name.clone(); + let author = info["Author"].as_str().unwrap_or("Unknown").to_string(); + let version = info["Version"].as_str().unwrap_or("1.0.0").to_string(); + let description = info["Description"].as_str().unwrap_or("").to_string(); + + let project = ModProject { + name: slug::slugify(&display_name), + display_name, + version, + description, + authors: vec![ModProjectAuthor::Name(author)], + license: None, + tags: Vec::new(), + champions: Vec::new(), + maps: Vec::new(), + transformers: Vec::new(), + layers: vec![ModProjectLayer { + name: "base".to_string(), + priority: 0, + description: None, + string_overrides: Default::default(), + }], + thumbnail: None, + }; + + let config_json = serde_json::to_string_pretty(&project)?; + fs::write(metadata_dir.join("mod.config.json"), config_json)?; + + Ok(()) +} diff --git a/crates/ltk_mod_lib/src/lib.rs b/crates/ltk_mod_lib/src/lib.rs new file mode 100644 index 0000000..e7dcb59 --- /dev/null +++ b/crates/ltk_mod_lib/src/lib.rs @@ -0,0 +1,44 @@ +//! Mod library management for LeagueToolkit. +//! +//! This crate provides the core business logic for managing League of Legends mods: +//! installing/uninstalling mods, managing profiles, building overlays, and +//! coordinating concurrent access via file locks. +//! +//! It has no Tauri dependency and is designed to be consumed by both the +//! `ltk-mod` CLI and the `ltk-manager` desktop app. +//! +//! # Usage +//! +//! ```no_run +//! use camino::Utf8Path; +//! use ltk_mod_lib::LibraryIndex; +//! +//! let storage = Utf8Path::new("/path/to/mods"); +//! let mut index = LibraryIndex::load(storage).unwrap(); +//! +//! index.install_mod(storage, Utf8Path::new("skin.modpkg")).unwrap(); +//! index.create_profile(storage, "ranked".to_string()).unwrap(); +//! index.toggle_mod("mod-id", true).unwrap(); +//! +//! index.save(storage).unwrap(); +//! ``` + +pub mod error; +pub mod index; +pub(crate) mod install; +pub mod lock; +pub mod overlay; +pub mod profile; +pub mod progress; +pub(crate) mod query; + +pub use error::{LibraryError, LibraryResult}; +pub use index::{LibraryIndex, LibraryModEntry, ModArchiveFormat}; +pub use install::{BulkInstallError, BulkInstallResult}; +pub use lock::StorageLock; +pub use overlay::OverlayConfig; +pub use profile::{Profile, ProfileSlug}; +pub use progress::{ + InstallProgress, NoOpReporter, OverlayProgress, OverlayStage, ProgressReporter, +}; +pub use query::{InstalledMod, ModLayer}; diff --git a/crates/ltk_mod_lib/src/lock.rs b/crates/ltk_mod_lib/src/lock.rs new file mode 100644 index 0000000..0775a39 --- /dev/null +++ b/crates/ltk_mod_lib/src/lock.rs @@ -0,0 +1,88 @@ +use std::fs::{self, File}; + +use camino::{Utf8Path, Utf8PathBuf}; +use fs2::FileExt; + +use crate::error::{LibraryError, LibraryResult}; + +/// Advisory file lock for the mod storage directory. +/// +/// Acquired before any write to `library.json`, released on drop. +/// Non-blocking: if the lock is held by another process, fails immediately +/// with a clear error. +#[derive(Debug)] +pub struct StorageLock { + _file: File, + path: Utf8PathBuf, +} + +impl StorageLock { + /// Acquire an exclusive lock on the storage directory. + /// + /// Creates `library.lock` in the storage root. Returns an error if + /// another process holds the lock. + pub fn acquire(storage_dir: &Utf8Path) -> LibraryResult { + fs::create_dir_all(storage_dir.as_std_path())?; + let path = storage_dir.join("library.lock"); + let file = File::create(path.as_std_path())?; + + file.try_lock_exclusive() + .map_err(|_| LibraryError::StorageLocked)?; + + Ok(Self { _file: file, path }) + } +} + +impl Drop for StorageLock { + fn drop(&mut self) { + if let Err(e) = self._file.unlock() { + tracing::warn!("Failed to release storage lock at {}: {}", self.path, e); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn acquire_lock_creates_file() { + let dir = tempfile::tempdir().unwrap(); + let dir_path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap(); + + let _lock = StorageLock::acquire(&dir_path).unwrap(); + assert!(dir_path.join("library.lock").exists()); + } + + #[test] + fn acquire_lock_creates_directory() { + let dir = tempfile::tempdir().unwrap(); + let dir_path = Utf8PathBuf::from_path_buf(dir.path().join("nested").join("dir")).unwrap(); + + let _lock = StorageLock::acquire(&dir_path).unwrap(); + assert!(dir_path.join("library.lock").exists()); + } + + #[test] + fn lock_released_on_drop() { + let dir = tempfile::tempdir().unwrap(); + let dir_path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap(); + + { + let _lock = StorageLock::acquire(&dir_path).unwrap(); + } + + // Should be able to acquire again after drop + let _lock2 = StorageLock::acquire(&dir_path).unwrap(); + } + + #[test] + fn double_lock_same_process_fails() { + let dir = tempfile::tempdir().unwrap(); + let dir_path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap(); + + let _lock1 = StorageLock::acquire(&dir_path).unwrap(); + let result = StorageLock::acquire(&dir_path); + assert!(matches!(result.unwrap_err(), LibraryError::StorageLocked)); + } +} diff --git a/crates/ltk_mod_lib/src/overlay.rs b/crates/ltk_mod_lib/src/overlay.rs new file mode 100644 index 0000000..a7f57ef --- /dev/null +++ b/crates/ltk_mod_lib/src/overlay.rs @@ -0,0 +1,147 @@ +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::sync::Arc; + +use camino::{Utf8Path, Utf8PathBuf}; +use ltk_modpkg::Modpkg; +use ltk_overlay::{FantomeContent, ModpkgContent}; + +use crate::error::{LibraryError, LibraryResult}; +use crate::index::{LibraryIndex, ModArchiveFormat}; +use crate::progress::ProgressReporter; + +const SCRIPTS_WAD: &str = "scripts.wad.client"; +const TFT_WAD: &str = "map22.wad.client"; + +/// Overlay build configuration. +pub struct OverlayConfig { + pub league_path: Utf8PathBuf, + pub patch_tft: bool, + pub block_scripts_wad: bool, + pub wad_blocklist: Vec, +} + +impl OverlayConfig { + /// Build the full blocked WADs list from config settings. + /// + /// Starts with the user-configured blocklist, then conditionally adds + /// `scripts.wad.client` and `map22.wad.client` based on flags. + pub fn build_blocked_wads_list(&self) -> Vec { + let mut blocked: Vec = self + .wad_blocklist + .iter() + .map(|w| w.to_lowercase()) + .collect(); + if self.block_scripts_wad && !blocked.contains(&SCRIPTS_WAD.to_string()) { + blocked.push(SCRIPTS_WAD.to_string()); + } + if !self.patch_tft && !blocked.contains(&TFT_WAD.to_string()) { + blocked.push(TFT_WAD.to_string()); + } + blocked + } +} + +/// Build the overlay for the active profile. +/// +/// Returns the overlay root directory path on success. +pub(crate) fn build_overlay( + storage_dir: &Utf8Path, + index: &LibraryIndex, + config: &OverlayConfig, + reporter: Arc, +) -> LibraryResult { + let game_dir = resolve_game_dir(&config.league_path)?; + let active_profile = index.active_profile()?; + + let profile_dir = storage_dir + .join("profiles") + .join(active_profile.slug.as_str()); + let overlay_root = profile_dir.join("overlay"); + + let mut builder = ltk_overlay::OverlayBuilder::new(game_dir, overlay_root.clone(), profile_dir) + .with_blocked_wads(config.build_blocked_wads_list()) + .with_enabled_mods(collect_enabled_mods(storage_dir, index)?) + .with_progress(move |progress| { + reporter.on_overlay_progress(progress); + }); + + builder + .build() + .map_err(|e| LibraryError::OverlayFailed(e.to_string()))?; + + Ok(overlay_root) +} + +/// Collect enabled mods as `ltk_overlay::EnabledMod` for the active profile. +pub fn collect_enabled_mods( + storage_dir: &Utf8Path, + index: &LibraryIndex, +) -> LibraryResult> { + let active_profile = index.active_profile()?; + let mut enabled_mods = Vec::new(); + + for mod_id in &active_profile.enabled_mods { + let Some(entry) = index.mods.iter().find(|m| &m.id == mod_id) else { + tracing::warn!("Mod {} in profile but not found in library", mod_id); + continue; + }; + + let archive_path = entry.archive_path(storage_dir); + if !archive_path.exists() { + tracing::warn!("Archive not found for mod {}: {}", entry.id, archive_path); + continue; + } + + let content: Box = match entry.format { + ModArchiveFormat::Fantome => Box::new( + FantomeContent::new(File::open(archive_path.as_std_path())?) + .map_err(|e| { + LibraryError::Fantome(format!("Failed to open fantome archive: {}", e)) + })? + .with_archive_path(archive_path.clone()), + ), + ModArchiveFormat::Modpkg => Box::new( + ModpkgContent::new(Modpkg::mount_from_reader(File::open( + archive_path.as_std_path(), + )?)?) + .with_archive_path(archive_path.clone()), + ), + }; + + let enabled_layers = + active_profile + .layer_states + .get(&entry.id) + .map(|states: &HashMap| { + states + .iter() + .filter(|(_, &enabled)| enabled) + .map(|(name, _)| name.clone()) + .collect::>() + }); + + enabled_mods.push(ltk_overlay::EnabledMod { + id: entry.id.clone(), + content, + enabled_layers, + }); + } + + Ok(enabled_mods) +} + +fn resolve_game_dir(league_path: &Utf8Path) -> LibraryResult { + let game_dir = league_path.join("Game"); + if game_dir.exists() { + return Ok(game_dir); + } + if league_path.join("DATA").exists() { + return Ok(league_path.to_path_buf()); + } + + Err(LibraryError::ValidationFailed(format!( + "League path does not look like an install root or a Game directory: {}", + league_path + ))) +} diff --git a/crates/ltk_mod_lib/src/profile.rs b/crates/ltk_mod_lib/src/profile.rs new file mode 100644 index 0000000..440ffde --- /dev/null +++ b/crates/ltk_mod_lib/src/profile.rs @@ -0,0 +1,222 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::index::LibraryIndex; + +/// Slugified profile name used as the filesystem directory name. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ProfileSlug(pub String); + +impl ProfileSlug { + pub fn from_name(name: &str) -> Option { + let s = slug::slugify(name); + if s.is_empty() { + None + } else { + Some(Self(s)) + } + } + + pub fn is_unique_in(&self, index: &LibraryIndex, exclude_id: Option<&str>) -> bool { + !index + .profiles + .iter() + .any(|p| p.slug == *self && exclude_id.is_none_or(|id| p.id != id)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ProfileSlug { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl From for ProfileSlug { + fn from(s: String) -> Self { + Self(s) + } +} + +/// A mod profile for organizing different mod configurations. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Profile { + pub id: String, + pub name: String, + #[serde(default)] + pub slug: ProfileSlug, + /// Mod IDs in overlay priority order (first = highest priority). + pub enabled_mods: Vec, + /// Display order of all mods (enabled and disabled). + pub mod_order: Vec, + /// Per-mod layer states: `mod_id → (layer_name → enabled)`. + #[serde(default)] + pub layer_states: HashMap>, + pub created_at: DateTime, + pub last_used: DateTime, +} + +impl Profile { + /// Find the correct insertion position in `enabled_mods` for a mod, + /// preserving relative order from `mod_order`. + pub fn insertion_position_for(&self, mod_id: &str) -> usize { + if let Some(order_pos) = self.mod_order.iter().position(|id| id == mod_id) { + self.enabled_mods + .iter() + .position(|id| { + self.mod_order + .iter() + .position(|oid| oid == id) + .is_none_or(|p| p > order_pos) + }) + .unwrap_or(self.enabled_mods.len()) + } else { + 0 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_profile(mod_order: Vec<&str>, enabled_mods: Vec<&str>) -> Profile { + Profile { + id: "test-profile".to_string(), + name: "Test".to_string(), + slug: ProfileSlug::from("test".to_string()), + enabled_mods: enabled_mods.into_iter().map(String::from).collect(), + mod_order: mod_order.into_iter().map(String::from).collect(), + layer_states: HashMap::new(), + created_at: Utc::now(), + last_used: Utc::now(), + } + } + + // ----------------------------------------------------------------------- + // ProfileSlug + // ----------------------------------------------------------------------- + + #[test] + fn slug_from_normal_name() { + let slug = ProfileSlug::from_name("My Profile").unwrap(); + assert_eq!(slug.as_str(), "my-profile"); + } + + #[test] + fn slug_from_special_characters() { + let slug = ProfileSlug::from_name("Ranked!! (2024)").unwrap(); + assert!(!slug.as_str().is_empty()); + assert!(!slug.as_str().contains('!')); + assert!(!slug.as_str().contains('(')); + } + + #[test] + fn slug_from_empty_name_returns_none() { + assert!(ProfileSlug::from_name("").is_none()); + } + + #[test] + fn slug_from_whitespace_only_returns_none() { + assert!(ProfileSlug::from_name(" ").is_none()); + } + + #[test] + fn slug_from_symbols_only_returns_none() { + assert!(ProfileSlug::from_name("!!!").is_none()); + } + + #[test] + fn slug_uniqueness_no_profiles() { + let index = LibraryIndex { + mods: Vec::new(), + profiles: Vec::new(), + active_profile_id: String::new(), + }; + let slug = ProfileSlug::from_name("test").unwrap(); + assert!(slug.is_unique_in(&index, None)); + } + + #[test] + fn slug_uniqueness_duplicate_detected() { + let index = LibraryIndex { + mods: Vec::new(), + profiles: vec![make_profile(vec![], vec![])], + active_profile_id: String::new(), + }; + let slug = ProfileSlug::from("test".to_string()); + assert!(!slug.is_unique_in(&index, None)); + } + + #[test] + fn slug_uniqueness_exclude_self() { + let index = LibraryIndex { + mods: Vec::new(), + profiles: vec![make_profile(vec![], vec![])], + active_profile_id: String::new(), + }; + let slug = ProfileSlug::from("test".to_string()); + assert!(slug.is_unique_in(&index, Some("test-profile"))); + } + + #[test] + fn slug_display() { + let slug = ProfileSlug::from("my-profile".to_string()); + assert_eq!(format!("{}", slug), "my-profile"); + } + + // ----------------------------------------------------------------------- + // Profile::insertion_position_for + // ----------------------------------------------------------------------- + + #[test] + fn insertion_position_empty_enabled_list() { + let profile = make_profile(vec!["a", "b", "c"], vec![]); + assert_eq!(profile.insertion_position_for("b"), 0); + } + + #[test] + fn insertion_position_first_in_order() { + // mod_order: [a, b, c], enabled: [b, c] + // inserting "a" (pos 0 in order) → should go before "b" (pos 1 in order) → position 0 + let profile = make_profile(vec!["a", "b", "c"], vec!["b", "c"]); + assert_eq!(profile.insertion_position_for("a"), 0); + } + + #[test] + fn insertion_position_middle() { + // mod_order: [a, b, c], enabled: [a, c] + // inserting "b" (pos 1 in order) → should go before "c" (pos 2 in order) → position 1 + let profile = make_profile(vec!["a", "b", "c"], vec!["a", "c"]); + assert_eq!(profile.insertion_position_for("b"), 1); + } + + #[test] + fn insertion_position_last() { + // mod_order: [a, b, c], enabled: [a, b] + // inserting "c" (pos 2 in order) → no enabled mod comes after → append → position 2 + let profile = make_profile(vec!["a", "b", "c"], vec!["a", "b"]); + assert_eq!(profile.insertion_position_for("c"), 2); + } + + #[test] + fn insertion_position_mod_not_in_order() { + let profile = make_profile(vec!["a", "b"], vec!["a"]); + assert_eq!(profile.insertion_position_for("unknown"), 0); + } + + #[test] + fn insertion_position_preserves_order_with_gaps() { + // mod_order: [a, b, c, d, e], enabled: [a, e] + // inserting "c" → should go between "a" (pos 0) and "e" (pos 4) → position 1 + let profile = make_profile(vec!["a", "b", "c", "d", "e"], vec!["a", "e"]); + assert_eq!(profile.insertion_position_for("c"), 1); + } +} diff --git a/crates/ltk_mod_lib/src/progress.rs b/crates/ltk_mod_lib/src/progress.rs new file mode 100644 index 0000000..e1f5cbf --- /dev/null +++ b/crates/ltk_mod_lib/src/progress.rs @@ -0,0 +1,30 @@ +pub use ltk_overlay::{OverlayProgress, OverlayStage}; + +/// Progress reporting trait for overlay and install operations. +/// +/// Implement this trait to receive progress updates during long-running operations. +/// The Tauri app implements this via event emission; the CLI implements it via terminal output. +pub trait ProgressReporter: Send + Sync { + /// Called during overlay build progress. + fn on_overlay_progress(&self, progress: OverlayProgress); + + /// Called during mod installation progress (batch installs). + fn on_install_progress(&self, progress: InstallProgress); +} + +/// Install progress information for batch operations. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallProgress { + pub current: usize, + pub total: usize, + pub current_file: String, +} + +/// No-op progress reporter that discards all events. +pub struct NoOpReporter; + +impl ProgressReporter for NoOpReporter { + fn on_overlay_progress(&self, _progress: OverlayProgress) {} + fn on_install_progress(&self, _progress: InstallProgress) {} +} diff --git a/crates/ltk_mod_lib/src/query.rs b/crates/ltk_mod_lib/src/query.rs new file mode 100644 index 0000000..141a0f0 --- /dev/null +++ b/crates/ltk_mod_lib/src/query.rs @@ -0,0 +1,67 @@ +use std::collections::HashSet; + +use camino::Utf8Path; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::error::LibraryResult; +use crate::index::LibraryIndex; +use crate::install::read_installed_mod; + +/// A mod entry with full metadata, ready for display or JSON output. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledMod { + pub id: String, + pub name: String, + pub display_name: String, + pub version: String, + pub description: Option, + pub authors: Vec, + pub enabled: bool, + pub installed_at: DateTime, + pub layers: Vec, + pub tags: Vec, + pub champions: Vec, + pub maps: Vec, + pub mod_dir: String, +} + +/// A mod layer with current enabled state. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModLayer { + pub name: String, + pub priority: i32, + pub enabled: bool, +} + +impl LibraryIndex { + /// Get all installed mods with their status in the active profile. + pub fn get_installed_mods(&self, storage_dir: &Utf8Path) -> LibraryResult> { + let active_profile = self.active_profile()?; + + let enabled_set: HashSet<&str> = active_profile + .enabled_mods + .iter() + .map(String::as_str) + .collect(); + + let mut result = Vec::new(); + for mod_id in &active_profile.mod_order { + let Some(entry) = self.mods.iter().find(|m| &m.id == mod_id) else { + continue; + }; + let enabled = enabled_set.contains(mod_id.as_str()); + let layer_states = active_profile.layer_states.get(mod_id.as_str()); + match read_installed_mod(entry, enabled, storage_dir, layer_states) { + Ok(m) => result.push(m), + Err(e) => { + tracing::warn!("Skipping broken mod entry {}: {}", entry.id, e); + } + } + } + + Ok(result) + } +} diff --git a/crates/ltk_mod_lib/tests/integration.rs b/crates/ltk_mod_lib/tests/integration.rs new file mode 100644 index 0000000..2b92f69 --- /dev/null +++ b/crates/ltk_mod_lib/tests/integration.rs @@ -0,0 +1,544 @@ +use std::collections::HashMap; +use std::fs; +use std::io::Write; + +use camino::{Utf8Path, Utf8PathBuf}; +use ltk_mod_lib::{LibraryError, LibraryIndex, NoOpReporter}; + +fn temp_storage() -> (tempfile::TempDir, Utf8PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap(); + (dir, path) +} + +/// Create a minimal .fantome archive (ZIP with META/info.json). +fn create_test_fantome(dir: &Utf8Path, name: &str) -> Utf8PathBuf { + let file_path = dir.join(format!("{}.fantome", name)); + let file = fs::File::create(file_path.as_std_path()).unwrap(); + let mut zip = zip::ZipWriter::new(file); + + let info = serde_json::json!({ + "Name": name, + "Author": "Test Author", + "Version": "1.0.0", + "Description": format!("A test mod called {}", name), + }); + + zip.start_file("META/info.json", zip::write::SimpleFileOptions::default()) + .unwrap(); + zip.write_all(info.to_string().as_bytes()).unwrap(); + zip.finish().unwrap(); + + file_path +} + +// --------------------------------------------------------------------------- +// Install flow +// --------------------------------------------------------------------------- + +#[test] +fn install_single_mod() { + let (_dir, storage) = temp_storage(); + let (_src_dir, src_path) = temp_storage(); + + let fantome = create_test_fantome(&src_path, "test-skin"); + let mut index = LibraryIndex::load(&storage).unwrap(); + + let installed = index.install_mod(&storage, &fantome).unwrap(); + assert_eq!(installed.display_name, "test-skin"); + assert_eq!(installed.version, "1.0.0"); + assert_eq!(installed.authors, vec!["Test Author"]); + assert!(installed.enabled); + + assert_eq!(index.mods.len(), 1); + let profile = index.active_profile().unwrap(); + assert_eq!(profile.enabled_mods.len(), 1); + assert_eq!(profile.mod_order.len(), 1); +} + +#[test] +fn install_mod_copies_archive() { + let (_dir, storage) = temp_storage(); + let (_src_dir, src_path) = temp_storage(); + + let fantome = create_test_fantome(&src_path, "copy-test"); + let mut index = LibraryIndex::load(&storage).unwrap(); + + let installed = index.install_mod(&storage, &fantome).unwrap(); + let archive = storage + .join("archives") + .join(format!("{}.fantome", installed.id)); + assert!(archive.exists()); +} + +#[test] +fn install_mod_extracts_metadata() { + let (_dir, storage) = temp_storage(); + let (_src_dir, src_path) = temp_storage(); + + let fantome = create_test_fantome(&src_path, "meta-test"); + let mut index = LibraryIndex::load(&storage).unwrap(); + + let installed = index.install_mod(&storage, &fantome).unwrap(); + let config = storage + .join("mods") + .join(&installed.id) + .join("mod.config.json"); + assert!(config.exists()); + + let contents = fs::read_to_string(config.as_std_path()).unwrap(); + let project: serde_json::Value = serde_json::from_str(&contents).unwrap(); + assert_eq!(project["display_name"], "meta-test"); + assert_eq!(project["version"], "1.0.0"); +} + +#[test] +fn install_mod_creates_base_layer() { + let (_dir, storage) = temp_storage(); + let (_src_dir, src_path) = temp_storage(); + + let fantome = create_test_fantome(&src_path, "layer-test"); + let mut index = LibraryIndex::load(&storage).unwrap(); + + let installed = index.install_mod(&storage, &fantome).unwrap(); + assert_eq!(installed.layers.len(), 1); + assert_eq!(installed.layers[0].name, "base"); + assert!(installed.layers[0].enabled); +} + +#[test] +fn install_mod_auto_enables_at_top() { + let (_dir, storage) = temp_storage(); + let (_src_dir, src_path) = temp_storage(); + + let mod1 = create_test_fantome(&src_path, "mod1"); + let mod2 = create_test_fantome(&src_path, "mod2"); + let mut index = LibraryIndex::load(&storage).unwrap(); + + let first = index.install_mod(&storage, &mod1).unwrap(); + let second = index.install_mod(&storage, &mod2).unwrap(); + + let profile = index.active_profile().unwrap(); + // Second mod should be at position 0 (highest priority) + assert_eq!(profile.enabled_mods[0], second.id); + assert_eq!(profile.enabled_mods[1], first.id); + assert_eq!(profile.mod_order[0], second.id); + assert_eq!(profile.mod_order[1], first.id); +} + +#[test] +fn install_mod_nonexistent_file_fails() { + let (_dir, storage) = temp_storage(); + let mut index = LibraryIndex::load(&storage).unwrap(); + + let result = index.install_mod(&storage, Utf8Path::new("/nonexistent/mod.fantome")); + assert!(matches!(result.unwrap_err(), LibraryError::InvalidPath(_))); +} + +// --------------------------------------------------------------------------- +// Batch install +// --------------------------------------------------------------------------- + +#[test] +fn batch_install_mods() { + let (_dir, storage) = temp_storage(); + let (_src_dir, src_path) = temp_storage(); + + let mod1 = create_test_fantome(&src_path, "batch1"); + let mod2 = create_test_fantome(&src_path, "batch2"); + let mod3 = create_test_fantome(&src_path, "batch3"); + + let mut index = LibraryIndex::load(&storage).unwrap(); + let result = index + .install_mods_batch(&storage, &[mod1, mod2, mod3], &NoOpReporter) + .unwrap(); + + assert_eq!(result.installed.len(), 3); + assert!(result.failed.is_empty()); + assert_eq!(index.mods.len(), 3); +} + +#[test] +fn batch_install_partial_failure() { + let (_dir, storage) = temp_storage(); + let (_src_dir, src_path) = temp_storage(); + + let good = create_test_fantome(&src_path, "good"); + let bad = Utf8PathBuf::from("/nonexistent/bad.fantome"); + let good2 = create_test_fantome(&src_path, "good2"); + + let mut index = LibraryIndex::load(&storage).unwrap(); + let result = index + .install_mods_batch(&storage, &[good, bad, good2], &NoOpReporter) + .unwrap(); + + assert_eq!(result.installed.len(), 2); + assert_eq!(result.failed.len(), 1); + assert!(result.failed[0].file_path.contains("bad.fantome")); +} + +#[test] +fn batch_install_empty() { + let (_dir, storage) = temp_storage(); + let mut index = LibraryIndex::load(&storage).unwrap(); + let result = index + .install_mods_batch(&storage, &[], &NoOpReporter) + .unwrap(); + assert!(result.installed.is_empty()); + assert!(result.failed.is_empty()); +} + +// --------------------------------------------------------------------------- +// Query installed mods +// --------------------------------------------------------------------------- + +#[test] +fn get_installed_mods_returns_all() { + let (_dir, storage) = temp_storage(); + let (_src_dir, src_path) = temp_storage(); + + let mut index = LibraryIndex::load(&storage).unwrap(); + let mod1 = create_test_fantome(&src_path, "query1"); + let mod2 = create_test_fantome(&src_path, "query2"); + index.install_mod(&storage, &mod1).unwrap(); + index.install_mod(&storage, &mod2).unwrap(); + + let mods = index.get_installed_mods(&storage).unwrap(); + assert_eq!(mods.len(), 2); +} + +#[test] +fn get_installed_mods_respects_mod_order() { + let (_dir, storage) = temp_storage(); + let (_src_dir, src_path) = temp_storage(); + + let mut index = LibraryIndex::load(&storage).unwrap(); + let m1 = create_test_fantome(&src_path, "first"); + let m2 = create_test_fantome(&src_path, "second"); + let i1 = index.install_mod(&storage, &m1).unwrap(); + let i2 = index.install_mod(&storage, &m2).unwrap(); + + // mod_order is [i2, i1] because each install prepends + let mods = index.get_installed_mods(&storage).unwrap(); + assert_eq!(mods[0].id, i2.id); + assert_eq!(mods[1].id, i1.id); +} + +#[test] +fn get_installed_mods_shows_enabled_status() { + let (_dir, storage) = temp_storage(); + let (_src_dir, src_path) = temp_storage(); + + let mut index = LibraryIndex::load(&storage).unwrap(); + let m1 = create_test_fantome(&src_path, "enabled"); + let m2 = create_test_fantome(&src_path, "disabled"); + let i1 = index.install_mod(&storage, &m1).unwrap(); + let _i2 = index.install_mod(&storage, &m2).unwrap(); + + // Disable the first mod + index.toggle_mod(&i1.id, false).unwrap(); + + let mods = index.get_installed_mods(&storage).unwrap(); + let enabled_mod = mods.iter().find(|m| m.display_name == "disabled").unwrap(); + let disabled_mod = mods.iter().find(|m| m.display_name == "enabled").unwrap(); + assert!(enabled_mod.enabled); + assert!(!disabled_mod.enabled); +} + +#[test] +fn get_installed_mods_layer_states() { + let (_dir, storage) = temp_storage(); + let (_src_dir, src_path) = temp_storage(); + + let mut index = LibraryIndex::load(&storage).unwrap(); + let m1 = create_test_fantome(&src_path, "layers"); + let i1 = index.install_mod(&storage, &m1).unwrap(); + + // Default: all layers enabled + let mods = index.get_installed_mods(&storage).unwrap(); + assert!(mods[0].layers[0].enabled); + + // Set base layer to disabled + let mut states = HashMap::new(); + states.insert("base".to_string(), false); + index.set_layer_states(&i1.id, states).unwrap(); + + let mods = index.get_installed_mods(&storage).unwrap(); + assert!(!mods[0].layers[0].enabled); +} + +#[test] +fn get_installed_mods_filters_empty_description() { + let (_dir, storage) = temp_storage(); + let (_src_dir, src_path) = temp_storage(); + + // Create a fantome with empty description + let file_path = src_path.join("empty-desc.fantome"); + let file = fs::File::create(file_path.as_std_path()).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let info = serde_json::json!({ + "Name": "empty-desc", + "Author": "Test", + "Version": "1.0.0", + "Description": "", + }); + zip.start_file("META/info.json", zip::write::SimpleFileOptions::default()) + .unwrap(); + zip.write_all(info.to_string().as_bytes()).unwrap(); + zip.finish().unwrap(); + + let mut index = LibraryIndex::load(&storage).unwrap(); + index.install_mod(&storage, &file_path).unwrap(); + + let mods = index.get_installed_mods(&storage).unwrap(); + assert!(mods[0].description.is_none()); +} + +// --------------------------------------------------------------------------- +// Uninstall flow +// --------------------------------------------------------------------------- + +#[test] +fn uninstall_mod_full_flow() { + let (_dir, storage) = temp_storage(); + let (_src_dir, src_path) = temp_storage(); + + let mut index = LibraryIndex::load(&storage).unwrap(); + let m1 = create_test_fantome(&src_path, "uninstall-test"); + let installed = index.install_mod(&storage, &m1).unwrap(); + + let mod_dir = storage.join("mods").join(&installed.id); + let archive = storage + .join("archives") + .join(format!("{}.fantome", installed.id)); + assert!(mod_dir.exists()); + assert!(archive.exists()); + + index.uninstall_mod(&storage, &installed.id).unwrap(); + + assert!(index.mods.is_empty()); + assert!(!mod_dir.exists()); + assert!(!archive.exists()); + + let profile = index.active_profile().unwrap(); + assert!(profile.enabled_mods.is_empty()); + assert!(profile.mod_order.is_empty()); +} + +// --------------------------------------------------------------------------- +// Full workflow: install → toggle → reorder → switch profile → uninstall +// --------------------------------------------------------------------------- + +#[test] +fn full_workflow() { + let (_dir, storage) = temp_storage(); + let (_src_dir, src_path) = temp_storage(); + + let mut index = LibraryIndex::load(&storage).unwrap(); + + // Install 3 mods + let m1 = create_test_fantome(&src_path, "skin-a"); + let m2 = create_test_fantome(&src_path, "skin-b"); + let m3 = create_test_fantome(&src_path, "skin-c"); + let i1 = index.install_mod(&storage, &m1).unwrap(); + let i2 = index.install_mod(&storage, &m2).unwrap(); + let i3 = index.install_mod(&storage, &m3).unwrap(); + + assert_eq!(index.mods.len(), 3); + + // All enabled by default + let profile = index.active_profile().unwrap(); + assert_eq!(profile.enabled_mods.len(), 3); + + // Disable one mod + index.toggle_mod(&i2.id, false).unwrap(); + let profile = index.active_profile().unwrap(); + assert_eq!(profile.enabled_mods.len(), 2); + assert!(!profile.enabled_mods.contains(&i2.id)); + + // Reorder: reverse the order + index + .reorder_mods(vec![i1.id.clone(), i2.id.clone(), i3.id.clone()]) + .unwrap(); + let profile = index.active_profile().unwrap(); + assert_eq!( + profile.mod_order, + vec![i1.id.clone(), i2.id.clone(), i3.id.clone()] + ); + // enabled_mods should still exclude i2 but now in order [i1, i3] + assert_eq!(profile.enabled_mods, vec![i1.id.clone(), i3.id.clone()]); + + // Re-enable i2 + index.toggle_mod(&i2.id, true).unwrap(); + let profile = index.active_profile().unwrap(); + assert_eq!( + profile.enabled_mods, + vec![i1.id.clone(), i2.id.clone(), i3.id.clone()] + ); + + // Create and switch to new profile + let ranked = index + .create_profile(&storage, "Ranked".to_string()) + .unwrap(); + assert!(ranked.enabled_mods.is_empty()); + assert_eq!(ranked.mod_order.len(), 3); + + index.switch_profile(&ranked.id).unwrap(); + assert_eq!(index.active_profile_id, ranked.id); + + // Enable only one mod in ranked profile + index.toggle_mod(&i1.id, true).unwrap(); + let profile = index.active_profile().unwrap(); + assert_eq!(profile.enabled_mods, vec![i1.id.clone()]); + + // Switch back and verify default profile unchanged + let default_id = index.profiles[0].id.clone(); + index.switch_profile(&default_id).unwrap(); + let profile = index.active_profile().unwrap(); + assert_eq!(profile.enabled_mods.len(), 3); + + // Uninstall a mod — should affect all profiles + index.uninstall_mod(&storage, &i1.id).unwrap(); + assert_eq!(index.mods.len(), 2); + + // Check both profiles were updated + for profile in &index.profiles { + assert!(!profile.enabled_mods.contains(&i1.id)); + assert!(!profile.mod_order.contains(&i1.id)); + } + + // Save and reload — verify persistence + index.save(&storage).unwrap(); + let reloaded = LibraryIndex::load(&storage).unwrap(); + assert_eq!(reloaded.mods.len(), 2); + assert_eq!(reloaded.profiles.len(), 2); +} + +// --------------------------------------------------------------------------- +// Persistence round-trip with complex state +// --------------------------------------------------------------------------- + +#[test] +fn persistence_preserves_profile_state() { + let (_dir, storage) = temp_storage(); + let (_src_dir, src_path) = temp_storage(); + + let mut index = LibraryIndex::load(&storage).unwrap(); + let m1 = create_test_fantome(&src_path, "persist-test"); + let installed = index.install_mod(&storage, &m1).unwrap(); + + // Set up complex state + let mut states = HashMap::new(); + states.insert("base".to_string(), false); + index + .set_layer_states(&installed.id, states.clone()) + .unwrap(); + index + .create_profile(&storage, "Ranked".to_string()) + .unwrap(); + + index.save(&storage).unwrap(); + let loaded = LibraryIndex::load(&storage).unwrap(); + + assert_eq!(loaded.profiles.len(), 2); + let default_profile = loaded.active_profile().unwrap(); + let layer_states = default_profile.layer_states.get(&installed.id).unwrap(); + assert_eq!(layer_states.get("base"), Some(&false)); +} + +// --------------------------------------------------------------------------- +// Fantome edge cases +// --------------------------------------------------------------------------- + +#[test] +fn install_fantome_with_minimal_info() { + let (_dir, storage) = temp_storage(); + let (_src_dir, src_path) = temp_storage(); + + // Create fantome with minimal fields + let file_path = src_path.join("minimal.fantome"); + let file = fs::File::create(file_path.as_std_path()).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let info = serde_json::json!({}); + zip.start_file("META/info.json", zip::write::SimpleFileOptions::default()) + .unwrap(); + zip.write_all(info.to_string().as_bytes()).unwrap(); + zip.finish().unwrap(); + + let mut index = LibraryIndex::load(&storage).unwrap(); + let installed = index.install_mod(&storage, &file_path).unwrap(); + + assert_eq!(installed.display_name, "unknown"); + assert_eq!(installed.version, "1.0.0"); + assert_eq!(installed.authors, vec!["Unknown"]); +} + +#[test] +fn install_fantome_without_meta_fails() { + let (_dir, storage) = temp_storage(); + let (_src_dir, src_path) = temp_storage(); + + // Create an empty ZIP with no META/info.json + let file_path = src_path.join("empty.fantome"); + let file = fs::File::create(file_path.as_std_path()).unwrap(); + let zip = zip::ZipWriter::new(file); + zip.finish().unwrap(); + + let mut index = LibraryIndex::load(&storage).unwrap(); + let result = index.install_mod(&storage, &file_path); + assert!(result.is_err()); +} + +// --------------------------------------------------------------------------- +// Profile isolation +// --------------------------------------------------------------------------- + +#[test] +fn profile_changes_are_isolated() { + let (_dir, storage) = temp_storage(); + let (_src_dir, src_path) = temp_storage(); + + let mut index = LibraryIndex::load(&storage).unwrap(); + let m1 = create_test_fantome(&src_path, "isolation-test"); + let installed = index.install_mod(&storage, &m1).unwrap(); + + // Create second profile and switch + let ranked = index + .create_profile(&storage, "Ranked".to_string()) + .unwrap(); + index.switch_profile(&ranked.id).unwrap(); + + // Enable mod in ranked + index.toggle_mod(&installed.id, true).unwrap(); + + // Set layer states in ranked + let mut states = HashMap::new(); + states.insert("base".to_string(), false); + index + .set_layer_states(&installed.id, states.clone()) + .unwrap(); + + // Switch back to default + let default_id = index.profiles[0].id.clone(); + index.switch_profile(&default_id).unwrap(); + + // Default profile layer states should be unaffected + let profile = index.active_profile().unwrap(); + assert!( + !profile.layer_states.contains_key(&installed.id) + || !profile.layer_states[&installed.id].contains_key("base") + || profile.layer_states[&installed.id]["base"] + ); +} + +// --------------------------------------------------------------------------- +// Storage lock +// --------------------------------------------------------------------------- + +#[test] +fn storage_lock_basic() { + let (_dir, storage) = temp_storage(); + let lock = ltk_mod_lib::StorageLock::acquire(&storage).unwrap(); + assert!(storage.join("library.lock").exists()); + drop(lock); +} diff --git a/crates/ltk_overlay/src/builder/mod.rs b/crates/ltk_overlay/src/builder/mod.rs index 5b59804..585dd0d 100644 --- a/crates/ltk_overlay/src/builder/mod.rs +++ b/crates/ltk_overlay/src/builder/mod.rs @@ -307,12 +307,13 @@ impl OverlayBuilder { self } - /// Set the ordered list of mods to include in the overlay. + /// Set the enabled mods for the overlay. /// /// Order matters: the first mod in the list (index 0) has the highest priority. /// When two mods override the same chunk, the mod closer to the front wins. - pub fn set_enabled_mods(&mut self, mods: Vec) { + pub fn with_enabled_mods(mut self, mods: Vec) -> Self { self.enabled_mods = mods; + self } /// Build the overlay with incremental rebuild support (two-pass). @@ -605,14 +606,14 @@ mod tests { } #[test] - fn test_set_enabled_mods() { - let mut builder = OverlayBuilder::new( + fn test_with_enabled_mods() { + let builder = OverlayBuilder::new( Utf8PathBuf::from("/game"), Utf8PathBuf::from("/profile/overlay"), Utf8PathBuf::from("/profile"), ); - builder.set_enabled_mods(vec![EnabledMod { + let builder = builder.with_enabled_mods(vec![EnabledMod { id: "mod1".to_string(), content: Box::new(FsModContent::new(Utf8PathBuf::from("/mods/mod1"))), enabled_layers: None,