From e01313066dbe51319f282e745a5abfcc2aa30188 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 12 Feb 2026 23:21:59 +0300 Subject: [PATCH 01/24] cli: wire shelve flag; more clippy fixes Signed-off-by: NotAShelf Change-Id: I694da71afe93bcb33687ff7d8e75f04f6a6a6964 --- src/cli/commands/export.rs | 2 +- src/cli/commands/fetch.rs | 4 ++-- src/cli/commands/fork.rs | 13 ++++++------- src/cli/commands/import.rs | 17 ++++++++++------- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/cli/commands/export.rs b/src/cli/commands/export.rs index 8528a45..6861553 100644 --- a/src/cli/commands/export.rs +++ b/src/cli/commands/export.rs @@ -35,7 +35,7 @@ pub async fn execute( let config_dir = config_path.parent().unwrap_or(Path::new(".")); // IPC coordination - prevent concurrent operations on the same modpack - let ipc = IpcCoordinator::new(&config_dir.to_path_buf())?; + let ipc = IpcCoordinator::new(config_dir)?; let ipc_timeout = std::time::Duration::from_secs(60); // Check for conflicting export operations diff --git a/src/cli/commands/fetch.rs b/src/cli/commands/fetch.rs index bdea8f7..9b88b1d 100644 --- a/src/cli/commands/fetch.rs +++ b/src/cli/commands/fetch.rs @@ -37,8 +37,8 @@ pub async fn execute( let operation_id = coordinator.register_operation(OperationType::Fetch)?; let _guard = OperationGuard::new(coordinator, operation_id); - // Create fetcher - let fetcher = Fetcher::new("."); + // Create fetcher with shelve option + let fetcher = Fetcher::new(".").with_shelve(args.shelve); // Fetch all projects (progress indicators handled in fetch.rs) fetcher.fetch_all(&lockfile, &config).await?; diff --git a/src/cli/commands/fork.rs b/src/cli/commands/fork.rs index 1627dc3..514257a 100644 --- a/src/cli/commands/fork.rs +++ b/src/cli/commands/fork.rs @@ -211,13 +211,12 @@ fn execute_init( .args(["log", "--limit", "1", "--template", ""]) .current_dir(path) .output() + && !output.stdout.is_empty() { - if !output.stdout.is_empty() { - println!( - "Note: Jujutsu repository detected. Make sure to run 'jj git \ - push' to sync changes with remote if needed." - ); - } + println!( + "Note: Jujutsu repository detected. Make sure to run 'jj git \ + push' to sync changes with remote if needed." + ); } }, VcsType::None => { @@ -256,7 +255,7 @@ fn execute_init( local_config.parent_config_hash = Some(config_hash); } - // Now clone from the local path into .pakku/parent — this avoids + // Now clone from the local path into .pakku/parent, this avoids // re-downloading objects let parent_path = Path::new(&parent_path_str); diff --git a/src/cli/commands/import.rs b/src/cli/commands/import.rs index cb6649c..c9a20da 100644 --- a/src/cli/commands/import.rs +++ b/src/cli/commands/import.rs @@ -134,16 +134,19 @@ async fn import_modrinth( { log::info!("Fetching project: {project_id}"); match platform - .request_project_with_files(project_id, &lockfile.mc_versions, &[ - loader.0.clone(), - ]) + .request_project_with_files( + project_id, + &lockfile.mc_versions, + std::slice::from_ref(&loader.0), + ) .await { Ok(mut project) => { // Select best file - if let Err(e) = - project.select_file(&lockfile.mc_versions, &[loader.0.clone()]) - { + if let Err(e) = project.select_file( + &lockfile.mc_versions, + std::slice::from_ref(&loader.0), + ) { log::warn!( "Failed to select file for {}: {}", project.get_name(), @@ -357,7 +360,7 @@ async fn import_curseforge( description: None, author: manifest["author"] .as_str() - .map(|s| s.to_string()), + .map(std::string::ToString::to_string), overrides: vec!["overrides".to_string()], server_overrides: None, client_overrides: None, From fb2c02d53dfdcb6730a9616e68c30a666f56712e Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 12 Feb 2026 23:22:16 +0300 Subject: [PATCH 02/24] platform: add CurseForge side detection from categories Signed-off-by: NotAShelf Change-Id: I62c5117ed97bbc2389330720b4761a716a6a6964 --- src/platform.rs | 5 +- src/platform/curseforge.rs | 198 ++++++++++++++++++++++++++++++++++++- 2 files changed, 195 insertions(+), 8 deletions(-) diff --git a/src/platform.rs b/src/platform.rs index af4a9a0..e64ebc1 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -8,13 +8,12 @@ use std::sync::Arc; pub use curseforge::CurseForgePlatform; pub use github::GitHubPlatform; pub use modrinth::ModrinthPlatform; -use once_cell::sync::Lazy; pub use traits::PlatformClient; use crate::{error::Result, rate_limiter::RateLimiter}; -static RATE_LIMITER: Lazy> = - Lazy::new(|| Arc::new(RateLimiter::new(None))); +static RATE_LIMITER: std::sync::LazyLock> = + std::sync::LazyLock::new(|| Arc::new(RateLimiter::new(None))); pub fn create_platform( platform: &str, diff --git a/src/platform/curseforge.rs b/src/platform/curseforge.rs index 5419501..a08c191 100644 --- a/src/platform/curseforge.rs +++ b/src/platform/curseforge.rs @@ -66,11 +66,81 @@ impl CurseForgePlatform { } } + /// Determine project side based on `CurseForge` categories. + /// `CurseForge` doesn't have explicit client/server fields like Modrinth, + /// so we infer from category names and IDs. + fn detect_side_from_categories( + categories: &[CurseForgeCategory], + ) -> ProjectSide { + // Known client-only category indicators (slugs and partial name matches) + const CLIENT_INDICATORS: &[&str] = &[ + "client", + "hud", + "gui", + "cosmetic", + "shader", + "optifine", + "resource-pack", + "texture", + "minimap", + "tooltip", + "inventory", + "quality-of-life", // Often client-side QoL + ]; + + // Known server-only category indicators + const SERVER_INDICATORS: &[&str] = &[ + "server-utility", + "bukkit", + "spigot", + "paper", + "admin-tools", + "anti-grief", + "economy", + "permissions", + "chat", + ]; + + let mut client_score = 0; + let mut server_score = 0; + + for category in categories { + let slug_lower = category.slug.to_lowercase(); + let name_lower = category.name.to_lowercase(); + + for indicator in CLIENT_INDICATORS { + if slug_lower.contains(indicator) || name_lower.contains(indicator) { + client_score += 1; + } + } + + for indicator in SERVER_INDICATORS { + if slug_lower.contains(indicator) || name_lower.contains(indicator) { + server_score += 1; + } + } + } + + // Only assign a specific side if there's clear indication + // and not conflicting signals + if client_score > 0 && server_score == 0 { + ProjectSide::Client + } else if server_score > 0 && client_score == 0 { + ProjectSide::Server + } else { + // Default to Both - works on both client and server + ProjectSide::Both + } + } + fn convert_project(&self, cf_project: CurseForgeProject) -> Project { let pakku_id = generate_pakku_id(); let project_type = Self::map_class_id(cf_project.class_id.unwrap_or(6)); - let mut project = Project::new(pakku_id, project_type, ProjectSide::Both); + // Detect side from categories + let side = Self::detect_side_from_categories(&cf_project.categories); + + let mut project = Project::new(pakku_id, project_type, side); project.add_platform( "curseforge".to_string(), @@ -317,11 +387,20 @@ impl PlatformClient for CurseForgePlatform { // CurseForge API models #[derive(Debug, Clone, Deserialize, Serialize)] struct CurseForgeProject { - id: u32, - name: String, - slug: String, + id: u32, + name: String, + slug: String, #[serde(rename = "classId")] - class_id: Option, + class_id: Option, + #[serde(default)] + categories: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct CurseForgeCategory { + id: u32, + name: String, + slug: String, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -381,3 +460,112 @@ struct CurseForgeFilesResponse { struct CurseForgeSearchResponse { data: Vec, } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_category(id: u32, name: &str, slug: &str) -> CurseForgeCategory { + CurseForgeCategory { + id, + name: name.to_string(), + slug: slug.to_string(), + } + } + + #[test] + fn test_detect_side_client_only() { + // HUD mod should be client-only + let categories = vec![ + make_category(1, "HUD Mods", "hud"), + make_category(2, "Fabric", "fabric"), + ]; + let side = CurseForgePlatform::detect_side_from_categories(&categories); + assert_eq!(side, ProjectSide::Client); + } + + #[test] + fn test_detect_side_server_only() { + // Server utility should be server-only + let categories = vec![ + make_category(1, "Server Utility", "server-utility"), + make_category(2, "Bukkit Plugins", "bukkit"), + ]; + let side = CurseForgePlatform::detect_side_from_categories(&categories); + assert_eq!(side, ProjectSide::Server); + } + + #[test] + fn test_detect_side_both() { + // Generic mod categories should be both + let categories = vec![ + make_category(1, "Technology", "technology"), + make_category(2, "Fabric", "fabric"), + ]; + let side = CurseForgePlatform::detect_side_from_categories(&categories); + assert_eq!(side, ProjectSide::Both); + } + + #[test] + fn test_detect_side_conflicting_signals() { + // Mixed categories should default to both + let categories = vec![ + make_category(1, "Client HUD", "client-hud"), + make_category(2, "Server Utility", "server-utility"), + ]; + let side = CurseForgePlatform::detect_side_from_categories(&categories); + assert_eq!(side, ProjectSide::Both); + } + + #[test] + fn test_detect_side_empty_categories() { + let categories = vec![]; + let side = CurseForgePlatform::detect_side_from_categories(&categories); + assert_eq!(side, ProjectSide::Both); + } + + #[test] + fn test_detect_side_gui_client() { + let categories = + vec![make_category(1, "GUI Enhancement", "gui-enhancement")]; + let side = CurseForgePlatform::detect_side_from_categories(&categories); + assert_eq!(side, ProjectSide::Client); + } + + #[test] + fn test_detect_side_permissions_server() { + let categories = vec![make_category(1, "Permissions", "permissions")]; + let side = CurseForgePlatform::detect_side_from_categories(&categories); + assert_eq!(side, ProjectSide::Server); + } + + #[test] + fn test_map_class_id() { + assert_eq!(CurseForgePlatform::map_class_id(6), ProjectType::Mod); + assert_eq!( + CurseForgePlatform::map_class_id(12), + ProjectType::ResourcePack + ); + assert_eq!( + CurseForgePlatform::map_class_id(6945), + ProjectType::DataPack + ); + assert_eq!(CurseForgePlatform::map_class_id(6552), ProjectType::Shader); + assert_eq!(CurseForgePlatform::map_class_id(17), ProjectType::World); + assert_eq!(CurseForgePlatform::map_class_id(9999), ProjectType::Mod); // Unknown + } + + #[test] + fn test_map_release_type() { + assert_eq!( + CurseForgePlatform::map_release_type(1), + ReleaseType::Release + ); + assert_eq!(CurseForgePlatform::map_release_type(2), ReleaseType::Beta); + assert_eq!(CurseForgePlatform::map_release_type(3), ReleaseType::Alpha); + assert_eq!( + CurseForgePlatform::map_release_type(99), + ReleaseType::Release + ); // Unknown + } +} From 3584117eb8f1b49d6a40e5bbded96c55c9d3733a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 12 Feb 2026 23:22:26 +0300 Subject: [PATCH 03/24] infra: add clippy allows; fix `PathBuf` -> `Path` Signed-off-by: NotAShelf Change-Id: I07795374f678fa2ec17b4171fa7e32276a6a6964 --- src/git/mod.rs | 14 ++++++-------- src/main.rs | 6 ++++++ src/rate_limiter.rs | 14 +++++++------- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/git/mod.rs b/src/git/mod.rs index f2b93a9..a0a1ba6 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -288,10 +288,9 @@ pub fn detect_vcs_type>(path: P) -> VcsType { .args(["root"]) .current_dir(path) .output() + && output.status.success() { - if output.status.success() { - return VcsType::Jujutsu; - } + return VcsType::Jujutsu; } // Check for git @@ -299,10 +298,9 @@ pub fn detect_vcs_type>(path: P) -> VcsType { .args(["rev-parse", "--show-toplevel"]) .current_dir(path) .output() + && output.status.success() { - if output.status.success() { - return VcsType::Git; - } + return VcsType::Git; } VcsType::None @@ -333,7 +331,7 @@ pub fn repo_has_uncommitted_changes>(path: P) -> Result { .current_dir(path) .output() .map_err(|e| { - PakkerError::GitError(format!("Failed to run jj status: {}", e)) + PakkerError::GitError(format!("Failed to run jj status: {e}")) })?; let output_str = String::from_utf8_lossy(&output.stdout); @@ -443,7 +441,7 @@ pub fn ahead_behind>( })?; Ok((ahead, behind)) } else { - // Remote ref missing — count commits reachable from local + // Remote ref missing, count commits reachable from local let ahead_count = count_commits(&repo, local_oid)?; Ok((ahead_count, 0)) } diff --git a/src/main.rs b/src/main.rs index eee865b..3fdbf3a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,9 @@ +// Allow pre-existing clippy warnings for functions with many arguments +// and complex types that would require significant refactoring +#![allow(clippy::too_many_arguments)] +#![allow(clippy::type_complexity)] +#![allow(clippy::large_enum_variant)] + mod cli; mod error; mod export; diff --git a/src/rate_limiter.rs b/src/rate_limiter.rs index dc5b97e..72dde8d 100644 --- a/src/rate_limiter.rs +++ b/src/rate_limiter.rs @@ -80,13 +80,13 @@ impl RateLimiter { platform_requests .retain(|t| now.duration_since(*t) < Duration::from_secs(60)); - if platform_requests.len() >= burst as usize { - if let Some(oldest) = platform_requests.first() { - let wait_time = interval.saturating_sub(now.duration_since(*oldest)); - if wait_time > Duration::ZERO { - drop(inner); - tokio::time::sleep(wait_time).await; - } + if platform_requests.len() >= burst as usize + && let Some(oldest) = platform_requests.first() + { + let wait_time = interval.saturating_sub(now.duration_since(*oldest)); + if wait_time > Duration::ZERO { + drop(inner); + tokio::time::sleep(wait_time).await; } } From 74a81e7cfda53c57b5258f01ae96427f38c6a2f9 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 12 Feb 2026 23:22:41 +0300 Subject: [PATCH 04/24] cli: add --all, --updates, --no-deps flags to commands Signed-off-by: NotAShelf Change-Id: I25581b8de945284b4ce7c2c85601a86f6a6a6964 --- src/cli.rs | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index a4b4b25..fea5e72 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -97,20 +97,20 @@ pub struct InitArgs { pub version: Option, /// Target platform - #[clap(short, long, default_value = "multiplatform")] - pub target: String, + #[clap(short, long)] + pub target: Option, - /// Minecraft version - #[clap(short, long, default_value = "1.20.1")] - pub mc_version: String, + /// Minecraft versions (space-separated) + #[clap(short, long = "mc-versions", value_delimiter = ' ', num_args = 1..)] + pub mc_versions: Option>, - /// Mod loader - #[clap(short, long, default_value = "fabric")] - pub loader: String, + /// Mod loaders (format: name=version, can be specified multiple times) + #[clap(short, long = "loaders", value_delimiter = ',')] + pub loaders: Option>, - /// Mod loader version - #[clap(short = 'v', long, default_value = "latest")] - pub loader_version: String, + /// Skip interactive prompts (use defaults) + #[clap(short, long)] + pub yes: bool, } #[derive(Args)] @@ -214,6 +214,10 @@ pub struct RmArgs { /// Skip confirmation prompt #[clap(short, long)] pub yes: bool, + + /// Skip removing dependent projects + #[clap(short = 'D', long = "no-deps")] + pub no_deps: bool, } #[derive(Args)] @@ -222,6 +226,10 @@ pub struct UpdateArgs { #[arg(value_name = "PROJECT")] pub inputs: Vec, + /// Update all projects + #[arg(short, long)] + pub all: bool, + /// Skip confirmation prompts #[arg(short, long)] pub yes: bool, @@ -344,7 +352,7 @@ pub struct SyncArgs { #[clap(short = 'R', long)] pub removals: bool, - /// Sync updates only + /// Sync updates only (apply pending updates) #[clap(short = 'U', long)] pub updates: bool, } @@ -371,7 +379,7 @@ pub struct ExportArgs { /// Export modpack without server content /// Modrinth: exclude server-overrides and SERVER mods - /// ServerPack: skip export + /// `ServerPack`: skip export #[clap(long = "no-server")] pub no_server: bool, } From a2d4a21fecbfce7d818beb9398cf78ce48b215a2 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 12 Feb 2026 23:22:54 +0300 Subject: [PATCH 05/24] chore: bump dependencies Signed-off-by: NotAShelf Change-Id: Ic1fda520473e53d1a584a3dda63ffda86a6a6964 --- Cargo.lock | 373 +++++++++++++++++++++++++++++++------ Cargo.toml | 50 ++--- src/model/project.rs | 26 ++- src/platform/curseforge.rs | 7 +- src/utils/id.rs | 2 +- 5 files changed, 363 insertions(+), 95 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e76806..a73442d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,7 +16,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -80,9 +80,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "assert-json-diff" @@ -211,6 +211,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "cipher" version = "0.4.4" @@ -223,9 +234,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.54" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" dependencies = [ "clap_builder", "clap_derive", @@ -233,9 +244,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" dependencies = [ "anstream", "anstyle", @@ -245,9 +256,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", @@ -257,9 +268,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "cmake" @@ -321,9 +332,9 @@ dependencies = [ [[package]] name = "constant_time_eq" -version = "0.3.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "core-foundation" @@ -360,6 +371,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.3.0" @@ -498,9 +518,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "0.1.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" dependencies = [ "log", "regex", @@ -508,9 +528,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" dependencies = [ "anstream", "anstyle", @@ -564,6 +584,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -706,10 +732,26 @@ dependencies = [ ] [[package]] -name = "git2" -version = "0.20.3" +name = "getrandom" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2b37e2f62729cdada11f0e6b3b6fe383c69c29fc619e391223e12856af308c" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.0", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" dependencies = [ "bitflags 2.10.0", "libc", @@ -720,6 +762,12 @@ dependencies = [ "url", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "h2" version = "0.4.13" @@ -739,6 +787,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -951,6 +1008,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -979,7 +1042,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -1108,6 +1173,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libbz2-rs-sys" version = "0.2.2" @@ -1116,9 +1187,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.181" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" [[package]] name = "libgit2-sys" @@ -1263,9 +1334,9 @@ dependencies = [ [[package]] name = "mockito" -version = "1.7.1" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e0603425789b4a70fcc4ac4f5a46a566c116ee3e2a6b768dc623f7719c611de" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" dependencies = [ "assert-json-diff", "bytes", @@ -1278,7 +1349,7 @@ dependencies = [ "hyper-util", "log", "pin-project-lite", - "rand", + "rand 0.9.2", "regex", "serde_json", "serde_urlencoded", @@ -1288,9 +1359,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "once_cell" @@ -1340,16 +1411,17 @@ dependencies = [ "env_logger", "futures", "git2", + "glob", "indicatif", "keyring", "libc", "log", "md-5", "mockito", - "once_cell", - "rand", + "rand 0.10.0", "regex", "reqwest", + "semver", "serde", "serde_json", "sha1", @@ -1357,7 +1429,7 @@ dependencies = [ "strsim", "tempfile", "textwrap", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "walkdir", "yansi", @@ -1453,9 +1525,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppmd-rust" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d558c559f0450f16f2a27a1f017ef38468c1090c9ce63c8e51366232d53717b4" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" [[package]] name = "ppv-lite86" @@ -1466,6 +1538,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.105" @@ -1489,7 +1571,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1505,13 +1587,13 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.2", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1553,7 +1635,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.3", +] + +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.1", + "rand_core 0.10.0", ] [[package]] @@ -1563,7 +1656,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", ] [[package]] @@ -1575,6 +1668,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1586,9 +1685,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -1615,9 +1714,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", @@ -1820,6 +1919,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -1882,7 +1987,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1893,7 +1998,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2031,12 +2136,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix", "windows-sys 0.61.2", @@ -2064,11 +2169,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -2084,9 +2189,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -2095,22 +2200,23 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "js-sys", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "tinystr" @@ -2258,6 +2364,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.19.0" @@ -2288,6 +2400,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unit-prefix" version = "0.5.2" @@ -2367,7 +2485,16 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -2428,6 +2555,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.83" @@ -2760,6 +2921,94 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.2" @@ -2891,9 +3140,9 @@ dependencies = [ [[package]] name = "zip" -version = "7.1.0" +version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9013f1222db8a6d680f13a7ccdc60a781199cd09c2fa4eff58e728bb181757fc" +checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980" dependencies = [ "aes", "bzip2", @@ -2901,8 +3150,7 @@ dependencies = [ "crc32fast", "deflate64", "flate2", - "generic-array", - "getrandom 0.3.4", + "getrandom 0.4.1", "hmac", "indexmap", "lzma-rust2", @@ -2911,6 +3159,7 @@ dependencies = [ "ppmd-rust", "sha1", "time", + "typed-path", "zeroize", "zopfli", "zstd", diff --git a/Cargo.toml b/Cargo.toml index 71acc35..411bb0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,47 +1,47 @@ [package] -name = "pakker" -version = "0.1.0" -edition = "2024" -authors = [ "NotAShelf " ] +rust-version = "1.91.0" +readme = true + [dependencies] -anyhow = "1.0.100" +anyhow = "1.0.101" async-trait = "0.1.89" -clap = { version = "4.5.54", features = [ "derive" ] } -comfy-table = "7.1" +clap = { version = "4.5.58", features = [ "derive" ] } +comfy-table = "7.2.2" dialoguer = "0.12.0" -env_logger = "0.11.8" +env_logger = "0.11.9" futures = "0.3.31" -git2 = "0.20.3" +git2 = "0.20.4" +glob = "0.3.3" indicatif = "0.18.3" keyring = "3.6.3" -libc = "0.2.180" +libc = "0.2.181" log = "0.4.29" md-5 = "0.10.6" -once_cell = "1.20" -rand = "0.9.2" -regex = "1.12" -reqwest = { version = "0.13.1", features = [ "json" ] } +rand = "0.10.0" +regex = "1.12.3" +reqwest = { version = "0.13.2", features = [ "json" ] } +semver = "1.0.27" serde = { version = "1.0.228", features = [ "derive" ] } serde_json = "1.0.149" sha1 = "0.10.6" -sha2 = "0.10.0" +sha2 = "0.10.9" strsim = "0.11.1" -tempfile = "3.24.0" -textwrap = "0.16" -thiserror = "2.0.17" +tempfile = "3.25.0" +textwrap = "0.16.2" +thiserror = "2.0.18" tokio = { version = "1.49.0", features = [ "full" ] } walkdir = "2.5.0" yansi = "1.0.1" -zip = "7.1.0" +zip = "7.4.0" [dev-dependencies] -mockito = "1.7.1" -tempfile = "3.24.0" - -[[bin]] -name = "pakker" -path = "src/main.rs" +mockito = "1.7.2" +tempfile = "3.25.0" # Optimize crypto stuff. Building them with optimizations makes that build script # run ~5x faster, more than offsetting the additional build time added to the diff --git a/src/model/project.rs b/src/model/project.rs index 96b98a6..af8b7e8 100644 --- a/src/model/project.rs +++ b/src/model/project.rs @@ -192,15 +192,29 @@ impl Project { return true; } - // Check if all providers have the same latest file name - // (simplified check - in reality would compare semantic versions) - let file_names: Vec<_> = versions_by_provider + // Compare semantic versions extracted from file names + let parse_version = |name: &str| { + // Try to extract version from patterns like "mod-1.0.0.jar" or + // "mod_v1.0.0" + let version_str = name + .rsplit_once('-') + .and_then(|(_, v)| v.strip_suffix("jar")) + .or_else(|| { + name + .rsplit_once('_') + .and_then(|(_, v)| v.strip_suffix("jar")) + }) + .unwrap_or(name); + semver::Version::parse(version_str).ok() + }; + + let versions: Vec<_> = versions_by_provider .values() - .filter_map(|files| files.first().copied()) + .filter_map(|files| files.first().copied().and_then(parse_version)) .collect(); - // All file names should be the same for versions to match - file_names.windows(2).all(|w| w[0] == w[1]) + // All versions should be the same + versions.windows(2).all(|w| w[0] == w[1]) } /// Check if versions do NOT match across providers. diff --git a/src/platform/curseforge.rs b/src/platform/curseforge.rs index a08c191..368f917 100644 --- a/src/platform/curseforge.rs +++ b/src/platform/curseforge.rs @@ -12,7 +12,12 @@ use crate::{ }; const CURSEFORGE_API_BASE: &str = "https://api.curseforge.com/v1"; +/// CurseForge game version type ID for loader versions (e.g., "fabric", +/// "forge") const LOADER_VERSION_TYPE_ID: i32 = 68441; +/// CurseForge relation type ID for "required dependency" (mod embeds or +/// requires another mod) +const DEPENDENCY_RELATION_TYPE_REQUIRED: u32 = 3; pub struct CurseForgePlatform { client: Client, @@ -194,7 +199,7 @@ impl CurseForgePlatform { required_dependencies: cf_file .dependencies .iter() - .filter(|d| d.relation_type == 3) + .filter(|d| d.relation_type == DEPENDENCY_RELATION_TYPE_REQUIRED) .map(|d| d.mod_id.to_string()) .collect(), size: cf_file.file_length, diff --git a/src/utils/id.rs b/src/utils/id.rs index 062f0dc..c664e7a 100644 --- a/src/utils/id.rs +++ b/src/utils/id.rs @@ -1,4 +1,4 @@ -use rand::Rng; +use rand::RngExt; const CHARSET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; From 0d64d4d1de5ec42d9999ba4e15c4f5b5273faad2 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 13 Feb 2026 00:14:21 +0300 Subject: [PATCH 06/24] platform: add mockito HTTP tests to modrinth Signed-off-by: NotAShelf Change-Id: I880c11195559fcfb9701e945a10fe87b6a6a6964 --- src/model/project.rs | 4 +- src/platform/modrinth.rs | 227 +++++++++++++++++++++++++++++++-------- 2 files changed, 182 insertions(+), 49 deletions(-) diff --git a/src/model/project.rs b/src/model/project.rs index af8b7e8..75fa917 100644 --- a/src/model/project.rs +++ b/src/model/project.rs @@ -198,11 +198,11 @@ impl Project { // "mod_v1.0.0" let version_str = name .rsplit_once('-') - .and_then(|(_, v)| v.strip_suffix("jar")) + .and_then(|(_, v)| v.strip_suffix(".jar")) .or_else(|| { name .rsplit_once('_') - .and_then(|(_, v)| v.strip_suffix("jar")) + .and_then(|(_, v)| v.strip_suffix(".jar")) }) .unwrap_or(name); semver::Version::parse(version_str).ok() diff --git a/src/platform/modrinth.rs b/src/platform/modrinth.rs index 34b3790..31a8bd2 100644 --- a/src/platform/modrinth.rs +++ b/src/platform/modrinth.rs @@ -24,6 +24,62 @@ impl ModrinthPlatform { } } + async fn request_project_url(&self, url: &str) -> Result { + let response = self.client.get(url).send().await?; + if !response.status().is_success() { + return Err(PakkerError::ProjectNotFound(url.to_string())); + } + let mr_project: ModrinthProject = response.json().await?; + Ok(self.convert_project(mr_project)) + } + + async fn request_project_files_url( + &self, + url: &str, + ) -> Result> { + let response = self.client.get(url).send().await?; + if !response.status().is_success() { + return Err(PakkerError::ProjectNotFound(url.to_string())); + } + let mr_versions: Vec = response.json().await?; + let project_id = url + .split('/') + .nth(4) + .ok_or_else(|| { + PakkerError::InvalidResponse( + "Cannot parse project ID from URL".to_string(), + ) + })? + .to_string(); + Ok( + mr_versions + .into_iter() + .map(|v| self.convert_version(v, &project_id)) + .collect(), + ) + } + + async fn lookup_by_hash_url(&self, url: &str) -> Result> { + let response = self.client.get(url).send().await?; + if response.status().as_u16() == 404 { + return Ok(None); + } + if !response.status().is_success() { + return Err(PakkerError::PlatformApiError(format!( + "Modrinth API error: {}", + response.status() + ))); + } + let version_data: serde_json::Value = response.json().await?; + let project_id = version_data["project_id"].as_str().ok_or_else(|| { + PakkerError::InvalidResponse("Missing project_id".to_string()) + })?; + self + .request_project_with_files(project_id, &[], &[]) + .await + .map(Some) + } + fn map_project_type(type_str: &str) -> ProjectType { match type_str { "mod" => ProjectType::Mod, @@ -123,15 +179,7 @@ impl PlatformClient for ModrinthPlatform { _loaders: &[String], ) -> Result { let url = format!("{MODRINTH_API_BASE}/project/{identifier}"); - - let response = self.client.get(&url).send().await?; - - if !response.status().is_success() { - return Err(PakkerError::ProjectNotFound(identifier.to_string())); - } - - let mr_project: ModrinthProject = response.json().await?; - Ok(self.convert_project(mr_project)) + self.request_project_url(&url).await } async fn request_project_files( @@ -170,20 +218,7 @@ impl PlatformClient for ModrinthPlatform { url.push_str(¶ms.join("&")); } - let response = self.client.get(&url).send().await?; - - if !response.status().is_success() { - return Err(PakkerError::ProjectNotFound(project_id.to_string())); - } - - let mr_versions: Vec = response.json().await?; - - Ok( - mr_versions - .into_iter() - .map(|v| self.convert_version(v, project_id)) - .collect(), - ) + self.request_project_files_url(&url).await } async fn request_project_with_files( @@ -213,30 +248,7 @@ impl PlatformClient for ModrinthPlatform { async fn lookup_by_hash(&self, hash: &str) -> Result> { // Modrinth uses SHA-1 hash for file lookups let url = format!("{MODRINTH_API_BASE}/version_file/{hash}"); - - let response = self.client.get(&url).send().await?; - - if response.status().as_u16() == 404 { - return Ok(None); - } - - if !response.status().is_success() { - return Err(PakkerError::PlatformApiError(format!( - "Modrinth API error: {}", - response.status() - ))); - } - - let version_data: serde_json::Value = response.json().await?; - - let project_id = version_data["project_id"].as_str().ok_or_else(|| { - PakkerError::InvalidResponse("Missing project_id".to_string()) - })?; - - self - .request_project_with_files(project_id, &[], &[]) - .await - .map(Some) + self.lookup_by_hash_url(&url).await } } @@ -280,3 +292,124 @@ struct ModrinthDependency { project_id: Option, dependency_type: String, } + +#[cfg(test)] +mod tests { + use reqwest::Client; + + use super::*; + + impl ModrinthPlatform { + fn with_client(client: Client) -> Self { + Self { client } + } + } + + async fn create_platform_with_mock() + -> (ModrinthPlatform, mockito::ServerGuard) { + let server = mockito::Server::new_async().await; + let client = Client::new(); + let platform = ModrinthPlatform::with_client(client); + (platform, server) + } + + #[tokio::test] + async fn test_request_project_success() { + let (platform, mut server) = create_platform_with_mock().await; + let url = format!("{}/project/test-mod", server.url()); + + let _mock = server + .mock("GET", "/project/test-mod") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "id": "abc123", + "slug": "test-mod", + "title": "Test Mod", + "project_type": "mod", + "client_side": "required", + "server_side": "required" + }"#, + ) + .create(); + + let result = platform.request_project_url(&url).await; + + assert!(result.is_ok()); + let project = result.unwrap(); + assert!(project.get_platform_id("modrinth").is_some()); + } + + #[tokio::test] + async fn test_request_project_not_found() { + let (platform, mut server) = create_platform_with_mock().await; + let url = format!("{}/project/nonexistent", server.url()); + + let _mock = server + .mock("GET", "/project/nonexistent") + .with_status(404) + .create(); + + let result = platform.request_project_url(&url).await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_request_project_files() { + let (platform, mut server) = create_platform_with_mock().await; + let url = format!("{}/project/abc123/version", server.url()); + + let _mock = server + .mock("GET", "/project/abc123/version") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"[ + { + "id": "v1", + "project_id": "abc123", + "name": "Test Mod v1.0.0", + "version_number": "1.0.0", + "game_versions": ["1.20.1"], + "version_type": "release", + "loaders": ["fabric"], + "date_published": "2024-01-01T00:00:00Z", + "files": [{ + "hashes": {"sha1": "abc123def456"}, + "url": "https://example.com/mod.jar", + "filename": "test-mod-1.0.0.jar", + "primary": true, + "size": 1024 + }], + "dependencies": [] + } + ]"#, + ) + .create(); + + let result = platform.request_project_files_url(&url).await; + + assert!(result.is_ok()); + let files = result.unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].file_name, "test-mod-1.0.0.jar"); + } + + #[tokio::test] + async fn test_lookup_by_hash_not_found() { + let (platform, mut server) = create_platform_with_mock().await; + let url = format!("{}/version_file/unknownhash123", server.url()); + + let _mock = server + .mock("GET", "/version_file/unknownhash123") + .with_status(404) + .create(); + + let result = platform.lookup_by_hash_url(&url).await; + + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } +} From 0e5fd4149657764336d6ff5b3012c0178da5aa60 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 18 Feb 2026 17:20:29 +0300 Subject: [PATCH 07/24] various: shared HTTP client with connection pooling Signed-off-by: NotAShelf Change-Id: Id13c17e9352da970a289f4e3ad909c5b6a6a6964 --- src/http.rs | 16 ++++++++++++++++ src/main.rs | 5 +---- src/platform.rs | 27 +++++++++++++++++++++++---- src/platform/curseforge.rs | 10 +++++++--- src/platform/github.rs | 6 +++--- src/platform/modrinth.rs | 20 ++++++++++++++------ 6 files changed, 64 insertions(+), 20 deletions(-) create mode 100644 src/http.rs diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 0000000..17ba51f --- /dev/null +++ b/src/http.rs @@ -0,0 +1,16 @@ +use std::time::Duration; + +use reqwest::Client; + +pub fn create_http_client() -> Client { + Client::builder() + .pool_max_idle_per_host(10) + .pool_idle_timeout(Duration::from_secs(30)) + .tcp_keepalive(Duration::from_secs(60)) + .tcp_nodelay(true) + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(30)) + .user_agent("Pakker/0.1.0") + .build() + .expect("Failed to build HTTP client") +} diff --git a/src/main.rs b/src/main.rs index 3fdbf3a..aa7a529 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod error; mod export; mod fetch; mod git; +mod http; mod ipc; mod model; mod platform; @@ -23,8 +24,6 @@ use clap::Parser; use cli::{Cli, Commands}; use error::PakkerError; -use crate::rate_limiter::RateLimiter; - #[tokio::main] async fn main() -> Result<(), PakkerError> { let cli = Cli::parse(); @@ -48,8 +47,6 @@ async fn main() -> Result<(), PakkerError> { let lockfile_path = working_dir.join("pakker-lock.json"); let config_path = working_dir.join("pakker.json"); - let _rate_limiter = std::sync::Arc::new(RateLimiter::new(None)); - match cli.command { Commands::Init(args) => { cli::commands::init::execute(args, &lockfile_path, &config_path).await diff --git a/src/platform.rs b/src/platform.rs index e64ebc1..c9e0589 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -10,11 +10,18 @@ pub use github::GitHubPlatform; pub use modrinth::ModrinthPlatform; pub use traits::PlatformClient; -use crate::{error::Result, rate_limiter::RateLimiter}; +use crate::{error::Result, http, rate_limiter::RateLimiter}; + +static HTTP_CLIENT: std::sync::LazyLock> = + std::sync::LazyLock::new(|| Arc::new(http::create_http_client())); static RATE_LIMITER: std::sync::LazyLock> = std::sync::LazyLock::new(|| Arc::new(RateLimiter::new(None))); +pub fn get_http_client() -> Arc { + HTTP_CLIENT.clone() +} + pub fn create_platform( platform: &str, api_key: Option, @@ -33,9 +40,21 @@ fn create_client( api_key: Option, ) -> Result> { match platform { - "modrinth" => Ok(Box::new(ModrinthPlatform::new())), - "curseforge" => Ok(Box::new(CurseForgePlatform::new(api_key))), - "github" => Ok(Box::new(GitHubPlatform::new(api_key))), + "modrinth" => { + Ok(Box::new(ModrinthPlatform::with_client(get_http_client()))) + }, + "curseforge" => { + Ok(Box::new(CurseForgePlatform::with_client( + get_http_client(), + api_key, + ))) + }, + "github" => { + Ok(Box::new(GitHubPlatform::with_client( + get_http_client(), + api_key, + ))) + }, _ => { Err(crate::error::PakkerError::ConfigError(format!( "Unknown platform: {platform}" diff --git a/src/platform/curseforge.rs b/src/platform/curseforge.rs index 368f917..d36efee 100644 --- a/src/platform/curseforge.rs +++ b/src/platform/curseforge.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::{collections::HashMap, sync::Arc}; use async_trait::async_trait; use reqwest::Client; @@ -20,18 +20,22 @@ const LOADER_VERSION_TYPE_ID: i32 = 68441; const DEPENDENCY_RELATION_TYPE_REQUIRED: u32 = 3; pub struct CurseForgePlatform { - client: Client, + client: Arc, api_key: Option, } impl CurseForgePlatform { pub fn new(api_key: Option) -> Self { Self { - client: Client::new(), + client: Arc::new(Client::new()), api_key, } } + pub fn with_client(client: Arc, api_key: Option) -> Self { + Self { client, api_key } + } + fn get_headers(&self) -> Result { let mut headers = reqwest::header::HeaderMap::new(); diff --git a/src/platform/github.rs b/src/platform/github.rs index cfc4f65..0c7a735 100644 --- a/src/platform/github.rs +++ b/src/platform/github.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::{collections::HashMap, sync::Arc}; use async_trait::async_trait; use regex::Regex; @@ -20,9 +20,9 @@ pub struct GitHubPlatform { } impl GitHubPlatform { - pub fn new(token: Option) -> Self { + pub fn with_client(client: Arc, token: Option) -> Self { Self { - client: Client::new(), + client: (*client).clone(), token, } } diff --git a/src/platform/modrinth.rs b/src/platform/modrinth.rs index 31a8bd2..69f81a2 100644 --- a/src/platform/modrinth.rs +++ b/src/platform/modrinth.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::{collections::HashMap, sync::Arc}; use async_trait::async_trait; use reqwest::Client; @@ -14,16 +14,20 @@ use crate::{ const MODRINTH_API_BASE: &str = "https://api.modrinth.com/v2"; pub struct ModrinthPlatform { - client: Client, + client: Arc, } impl ModrinthPlatform { pub fn new() -> Self { Self { - client: Client::new(), + client: Arc::new(Client::new()), } } + pub fn with_client(client: Arc) -> Self { + Self { client } + } + async fn request_project_url(&self, url: &str) -> Result { let response = self.client.get(url).send().await?; if !response.status().is_success() { @@ -295,13 +299,17 @@ struct ModrinthDependency { #[cfg(test)] mod tests { + use std::sync::Arc; + use reqwest::Client; use super::*; impl ModrinthPlatform { - fn with_client(client: Client) -> Self { - Self { client } + fn with_raw_client(client: Client) -> Self { + Self { + client: Arc::new(client), + } } } @@ -309,7 +317,7 @@ mod tests { -> (ModrinthPlatform, mockito::ServerGuard) { let server = mockito::Server::new_async().await; let client = Client::new(); - let platform = ModrinthPlatform::with_client(client); + let platform = ModrinthPlatform::with_raw_client(client); (platform, server) } From 885cbd5da6bd4eae588ca73f6fad92953276e547 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 19 Feb 2026 00:14:57 +0300 Subject: [PATCH 08/24] rate-limiter. re-acquire lock after sleep to prevent use-after-free I thought rust fixed this... Signed-off-by: NotAShelf Change-Id: I3a2fe427cdc19a6317510e8736fe46d56a6a6964 --- src/rate_limiter.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/rate_limiter.rs b/src/rate_limiter.rs index 72dde8d..871c666 100644 --- a/src/rate_limiter.rs +++ b/src/rate_limiter.rs @@ -87,12 +87,14 @@ impl RateLimiter { if wait_time > Duration::ZERO { drop(inner); tokio::time::sleep(wait_time).await; + let mut inner = self.inner.lock().await; + let platform_requests = + inner.requests.entry(platform.to_string()).or_default(); + platform_requests.push(Instant::now()); + return Ok(()); } } - let mut inner = self.inner.lock().await; - let platform_requests = - inner.requests.entry(platform.to_string()).or_default(); platform_requests.push(Instant::now()); Ok(()) From 00f54426796e96e0d2b472411e47cb6729381c58 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Feb 2026 19:14:45 +0300 Subject: [PATCH 09/24] model/lockfile: revert `get_project` to O(n) linear scan; remove unused project index Signed-off-by: NotAShelf Change-Id: I3a35ab4ce5dec8ce95a736440fa293fe6a6a6964 --- src/model/lockfile.rs | 109 +++++++++++++++++++++++------------------- 1 file changed, 61 insertions(+), 48 deletions(-) diff --git a/src/model/lockfile.rs b/src/model/lockfile.rs index dcda70d..eb0a64b 100644 --- a/src/model/lockfile.rs +++ b/src/model/lockfile.rs @@ -578,6 +578,67 @@ pub struct LockFile { pub lockfile_version: u32, } +impl LockFile { + pub fn new( + target: Option, + mc_versions: Vec, + loaders: HashMap, + ) -> Self { + Self { + target, + mc_versions, + loaders, + projects: Vec::new(), + lockfile_version: LOCKFILE_VERSION, + } + } + + pub fn get_project(&self, pakku_id: &str) -> Option<&Project> { + self + .projects + .iter() + .find(|p| p.pakku_id.as_deref() == Some(pakku_id)) + } + + pub fn get_loader_names(&self) -> Vec { + self.loaders.keys().cloned().collect() + } + + pub fn remove_project(&mut self, pakku_id: &str) -> Option { + if let Some(pos) = self + .projects + .iter() + .position(|p| p.pakku_id.as_deref() == Some(pakku_id)) + { + Some(self.projects.remove(pos)) + } else { + None + } + } + + pub fn find_project(&self, pakku_id: &str) -> Option<&Project> { + self.get_project(pakku_id) + } + + pub fn find_project_mut(&mut self, pakku_id: &str) -> Option<&mut Project> { + self + .projects + .iter_mut() + .find(|p| p.pakku_id.as_deref() == Some(pakku_id)) + } + + pub fn find_project_by_platform_id( + &self, + platform: &str, + id: &str, + ) -> Option<&Project> { + self + .projects + .iter() + .find(|p| p.id.get(platform).is_some_and(|pid| pid == id)) + } +} + impl LockFile { pub fn load>(path: P) -> Result { Self::load_with_validation(path, true) @@ -720,52 +781,4 @@ impl LockFile { self.projects.push(project); self.projects.sort_by_key(super::project::Project::get_name); } - - pub fn get_project(&self, pakku_id: &str) -> Option<&Project> { - self - .projects - .iter() - .find(|p| p.pakku_id.as_deref() == Some(pakku_id)) - } - - pub fn get_loader_names(&self) -> Vec { - self.loaders.keys().cloned().collect() - } - - pub fn remove_project(&mut self, pakku_id: &str) -> Option { - if let Some(pos) = self - .projects - .iter() - .position(|p| p.pakku_id.as_deref() == Some(pakku_id)) - { - Some(self.projects.remove(pos)) - } else { - None - } - } - - pub fn find_project(&self, pakku_id: &str) -> Option<&Project> { - self - .projects - .iter() - .find(|p| p.pakku_id.as_deref() == Some(pakku_id)) - } - - pub fn find_project_mut(&mut self, pakku_id: &str) -> Option<&mut Project> { - self - .projects - .iter_mut() - .find(|p| p.pakku_id.as_deref() == Some(pakku_id)) - } - - pub fn find_project_by_platform_id( - &self, - platform: &str, - id: &str, - ) -> Option<&Project> { - self - .projects - .iter() - .find(|p| p.id.get(platform).is_some_and(|pid| pid == id)) - } } From 28d1763aeebf2852a55a9d1072158ae4687d8b4f Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Feb 2026 19:37:27 +0300 Subject: [PATCH 10/24] export: introduce `export_profile to reduce boilerplate; cleanup Signed-off-by: NotAShelf Change-Id: I30469f47be8df27ee2a31f1acdcd68a16a6a6964 --- src/export/profiles.rs | 112 ++++++++++++++++++++++------------------- 1 file changed, 61 insertions(+), 51 deletions(-) diff --git a/src/export/profiles.rs b/src/export/profiles.rs index f132f07..d36a28b 100644 --- a/src/export/profiles.rs +++ b/src/export/profiles.rs @@ -6,65 +6,75 @@ pub trait ExportProfile { fn rules(&self) -> Vec>; } -pub struct CurseForgeProfile; -pub struct ModrinthProfile; -pub struct ServerPackProfile; +/// Implements [`ExportProfile`] for a unit struct with a static name and rule +/// list. +/// +/// Each rule entry is an expression evaluated as +/// `Box::new(super::rules::)`, supporting both bare unit struct names and +/// constructor calls with arguments. +/// +/// # Example +/// +/// ```ignore +/// export_profile! { +/// MyProfile => "my-profile" { +/// SomeRule, +/// AnotherRule::new("arg"), +/// } +/// } +/// ``` +macro_rules! export_profile { + ($struct:ident => $name:literal { $($rule:expr),* $(,)? }) => { + pub struct $struct; -impl ExportProfile for CurseForgeProfile { - fn name(&self) -> &'static str { - "curseforge" - } + impl ExportProfile for $struct { + fn name(&self) -> &'static str { + $name + } - fn rules(&self) -> Vec> { - vec![ - Box::new(super::rules::CopyProjectFilesRule), - Box::new(super::rules::FilterByPlatformRule), - Box::new(super::rules::MissingProjectsAsOverridesRule::new( - "curseforge", - )), - Box::new(super::rules::CopyOverridesRule), - Box::new(super::rules::CopyClientOverridesRule), - Box::new(super::rules::FilterServerOnlyRule), - Box::new(super::rules::GenerateManifestRule::curseforge()), - Box::new(super::rules::FilterNonRedistributableRule), - Box::new(super::rules::TextReplacementRule), - ] + fn rules(&self) -> Vec> { + use super::rules::*; + vec![ + $(Box::new($rule)),* + ] + } + } + }; +} + +export_profile! { + CurseForgeProfile => "curseforge" { + CopyProjectFilesRule, + FilterByPlatformRule, + MissingProjectsAsOverridesRule::new("curseforge"), + CopyOverridesRule, + CopyClientOverridesRule, + FilterServerOnlyRule, + GenerateManifestRule::curseforge(), + FilterNonRedistributableRule, + TextReplacementRule } } -impl ExportProfile for ModrinthProfile { - fn name(&self) -> &'static str { - "modrinth" - } - - fn rules(&self) -> Vec> { - vec![ - Box::new(super::rules::CopyProjectFilesRule), - Box::new(super::rules::FilterByPlatformRule), - Box::new(super::rules::MissingProjectsAsOverridesRule::new( - "modrinth", - )), - Box::new(super::rules::CopyOverridesRule), - Box::new(super::rules::CopyClientOverridesRule), - Box::new(super::rules::FilterServerOnlyRule), - Box::new(super::rules::GenerateManifestRule::modrinth()), - Box::new(super::rules::TextReplacementRule), - ] +export_profile! { + ModrinthProfile => "modrinth" { + CopyProjectFilesRule, + FilterByPlatformRule, + MissingProjectsAsOverridesRule::new("modrinth"), + CopyOverridesRule, + CopyClientOverridesRule, + FilterServerOnlyRule, + GenerateManifestRule::modrinth(), + TextReplacementRule } } -impl ExportProfile for ServerPackProfile { - fn name(&self) -> &'static str { - "serverpack" - } - - fn rules(&self) -> Vec> { - vec![ - Box::new(super::rules::CopyProjectFilesRule), - Box::new(super::rules::CopyServerOverridesRule), - Box::new(super::rules::FilterClientOnlyRule), - Box::new(super::rules::TextReplacementRule), - ] +export_profile! { + ServerPackProfile => "serverpack" { + CopyProjectFilesRule, + CopyServerOverridesRule, + FilterClientOnlyRule, + TextReplacementRule } } From 829d52c95bad7bef4b971d1e488891a1cbd47d19 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Feb 2026 19:50:55 +0300 Subject: [PATCH 11/24] rate-limiter: re-validate burst window in loop after sleep Signed-off-by: NotAShelf Change-Id: Iaa63eef1f055b9a98c90739025bd3ff36a6a6964 --- src/rate_limiter.rs | 41 +++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/src/rate_limiter.rs b/src/rate_limiter.rs index 871c666..f5832f8 100644 --- a/src/rate_limiter.rs +++ b/src/rate_limiter.rs @@ -72,32 +72,29 @@ impl RateLimiter { let interval = Duration::from_secs(60) / rate.max(1); - let mut inner = self.inner.lock().await; - let now = Instant::now(); - let platform_requests = - inner.requests.entry(platform.to_string()).or_default(); + loop { + let mut inner = self.inner.lock().await; + let now = Instant::now(); + let platform_requests = + inner.requests.entry(platform.to_string()).or_default(); - platform_requests - .retain(|t| now.duration_since(*t) < Duration::from_secs(60)); + platform_requests + .retain(|t| now.duration_since(*t) < Duration::from_secs(60)); - if platform_requests.len() >= burst as usize - && let Some(oldest) = platform_requests.first() - { - let wait_time = interval.saturating_sub(now.duration_since(*oldest)); - if wait_time > Duration::ZERO { - drop(inner); - tokio::time::sleep(wait_time).await; - let mut inner = self.inner.lock().await; - let platform_requests = - inner.requests.entry(platform.to_string()).or_default(); - platform_requests.push(Instant::now()); - return Ok(()); + if platform_requests.len() >= burst as usize + && let Some(oldest) = platform_requests.first() + { + let wait_time = interval.saturating_sub(now.duration_since(*oldest)); + if wait_time > Duration::ZERO { + drop(inner); + tokio::time::sleep(wait_time).await; + continue; + } } + + platform_requests.push(Instant::now()); + return Ok(()); } - - platform_requests.push(Instant::now()); - - Ok(()) } pub async fn wait_for(&self, platform: &str) { From 1adccf85873ef74518c8fe92390b159c9be6bc01 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Feb 2026 19:55:36 +0300 Subject: [PATCH 12/24] lockfile: remove unused `new` method Signed-off-by: NotAShelf Change-Id: If64632acaf9805b68a662e25aebe60216a6a6964 --- src/model/lockfile.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/model/lockfile.rs b/src/model/lockfile.rs index eb0a64b..bc80cee 100644 --- a/src/model/lockfile.rs +++ b/src/model/lockfile.rs @@ -579,20 +579,6 @@ pub struct LockFile { } impl LockFile { - pub fn new( - target: Option, - mc_versions: Vec, - loaders: HashMap, - ) -> Self { - Self { - target, - mc_versions, - loaders, - projects: Vec::new(), - lockfile_version: LOCKFILE_VERSION, - } - } - pub fn get_project(&self, pakku_id: &str) -> Option<&Project> { self .projects From a1357b2501fe0d11bcd3cdd0a9ccda05cb1092d2 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 27 Feb 2026 21:27:57 +0300 Subject: [PATCH 13/24] treewide: general cleanup Finally had the time to clean up after myself. Does a bunch of things, without breakage as far as I'm aware. I've removed around 20 unnecessary clones, and simplified the architechture a little bit. Signed-off-by: NotAShelf Change-Id: I4d22337b997a3bf5b0593e6068cd1bd86a6a6964 --- src/fetch.rs | 38 ++++++------------------------------ src/http.rs | 11 ++++++++++- src/ipc.rs | 21 ++++++++++---------- src/main.rs | 46 ++++++++++++++++++++++---------------------- src/model/project.rs | 17 ++++++++-------- src/rate_limiter.rs | 40 ++++++++++++++++++++++++++------------ 6 files changed, 87 insertions(+), 86 deletions(-) diff --git a/src/fetch.rs b/src/fetch.rs index 9e1d618..5049c24 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -24,12 +24,6 @@ pub struct Fetcher { shelve: bool, } -pub struct FileFetcher { - client: Client, - base_path: PathBuf, - shelve: bool, -} - impl Fetcher { pub fn new>(base_path: P) -> Self { Self { @@ -44,25 +38,10 @@ impl Fetcher { self } - pub async fn fetch_all( - &self, - lockfile: &LockFile, - config: &Config, - ) -> Result<()> { - let fetcher = FileFetcher { - client: self.client.clone(), - base_path: self.base_path.clone(), - shelve: self.shelve, - }; - fetcher.fetch_all(lockfile, config).await - } - pub async fn sync(&self, lockfile: &LockFile, config: &Config) -> Result<()> { self.fetch_all(lockfile, config).await } -} -impl FileFetcher { /// Fetch all project files according to lockfile with parallel downloads pub async fn fetch_all( &self, @@ -94,14 +73,14 @@ impl FileFetcher { let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_DOWNLOADS)); // Prepare download tasks + let client = &self.client; + let base_path = &self.base_path; let download_tasks: Vec<_> = exportable_projects .iter() .map(|project| { let semaphore = Arc::clone(&semaphore); - let client = self.client.clone(); - let base_path = self.base_path.clone(); - let lockfile = lockfile.clone(); - let config = config.clone(); + let client = client.clone(); + let base_path = base_path.clone(); let project = (*project).clone(); let overall_bar = overall_bar.clone(); @@ -111,11 +90,7 @@ impl FileFetcher { PakkerError::InternalError("Semaphore acquisition failed".into()) })?; - let name = project - .name - .values() - .next() - .map_or("unknown".to_string(), std::clone::Clone::clone); + let name = project.get_name(); let fetcher = Self { client, @@ -123,8 +98,7 @@ impl FileFetcher { shelve: false, // Shelving happens at sync level, not per-project }; - let result = - fetcher.fetch_project(&project, &lockfile, &config).await; + let result = fetcher.fetch_project(&project, lockfile, config).await; // Update progress bar overall_bar.inc(1); diff --git a/src/http.rs b/src/http.rs index 17ba51f..789c7b5 100644 --- a/src/http.rs +++ b/src/http.rs @@ -2,6 +2,12 @@ use std::time::Duration; use reqwest::Client; +/// Create HTTP client with optimized settings for API requests. +/// +/// # Panics +/// +/// Panics if the HTTP client cannot be built, which should only happen in +/// extreme cases like OOM or broken TLS configuration. pub fn create_http_client() -> Client { Client::builder() .pool_max_idle_per_host(10) @@ -12,5 +18,8 @@ pub fn create_http_client() -> Client { .timeout(Duration::from_secs(30)) .user_agent("Pakker/0.1.0") .build() - .expect("Failed to build HTTP client") + .expect( + "Failed to build HTTP client - this should never happen unless system \ + resources are exhausted", + ) } diff --git a/src/ipc.rs b/src/ipc.rs index d1aceaa..959ead8 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -153,17 +153,18 @@ impl IpcCoordinator { let ipc_dir = ipc_base.join(&modpack_hash); // Create IPC directory with restricted permissions - if let Err(e) = fs::create_dir_all(&ipc_dir) - && !ipc_dir.exists() - { - return Err(IpcError::IpcDirCreationFailed(e.to_string())); - } + fs::create_dir_all(&ipc_dir).or_else(|e| { + if ipc_dir.exists() { + Ok(()) + } else { + Err(IpcError::IpcDirCreationFailed(e.to_string())) + } + })?; - if ipc_dir.exists() { - // Set permissions to 700 (owner only) - if let Ok(metadata) = fs::metadata(&ipc_dir) - && metadata.permissions().mode() != 0o700 - { + // Set permissions to 700 (owner only) + if let Ok(metadata) = fs::metadata(&ipc_dir) { + let current_mode = metadata.permissions().mode() & 0o777; + if current_mode != 0o700 { let mut perms = metadata.permissions(); perms.set_mode(0o700); let _ = fs::set_permissions(&ipc_dir, perms); diff --git a/src/main.rs b/src/main.rs index aa7a529..95f0aed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,15 +59,15 @@ async fn main() -> Result<(), PakkerError> { }, Commands::AddPrj(args) => { cli::commands::add_prj::execute( - args.curseforge.clone(), - args.modrinth.clone(), - args.github.clone(), + args.curseforge, + args.modrinth, + args.github, args.project_type, args.side, args.strategy, args.redistributable, - args.subpath.clone(), - args.aliases.clone(), + args.subpath, + args.aliases, args.export, args.no_deps, args.yes, @@ -121,12 +121,12 @@ async fn main() -> Result<(), PakkerError> { .await }, Commands::Credentials(args) => { - match &args.subcommand { + match args.subcommand { Some(cli::CredentialsSubcommand::Set(set_args)) => { cli::commands::credentials_set::execute( - set_args.cf_api_key.clone(), - set_args.modrinth_token.clone(), - set_args.gh_access_token.clone(), + set_args.cf_api_key, + set_args.modrinth_token, + set_args.gh_access_token, ) }, None => { @@ -139,34 +139,34 @@ async fn main() -> Result<(), PakkerError> { } }, Commands::Cfg(args) => { - match &args.subcommand { + match args.subcommand { Some(cli::CfgSubcommand::Prj(prj_args)) => { cli::commands::cfg_prj::execute( &config_path, &lockfile_path, - prj_args.project.clone(), + prj_args.project, prj_args.r#type.as_deref(), prj_args.side.as_deref(), prj_args.update_strategy.as_deref(), prj_args.redistributable, - prj_args.subpath.clone(), - prj_args.add_alias.clone(), - prj_args.remove_alias.clone(), + prj_args.subpath, + prj_args.add_alias, + prj_args.remove_alias, prj_args.export, ) }, None => { cli::commands::cfg::execute( &config_path, - args.name.clone(), - args.version.clone(), - args.description.clone(), - args.author.clone(), - args.mods_path.clone(), - args.resource_packs_path.clone(), - args.data_packs_path.clone(), - args.worlds_path.clone(), - args.shaders_path.clone(), + args.name, + args.version, + args.description, + args.author, + args.mods_path, + args.resource_packs_path, + args.data_packs_path, + args.worlds_path, + args.shaders_path, ) }, } diff --git a/src/model/project.rs b/src/model/project.rs index 75fa917..dd3acdc 100644 --- a/src/model/project.rs +++ b/src/model/project.rs @@ -91,12 +91,13 @@ impl Project { } pub fn get_name(&self) -> String { - self.name.values().next().cloned().unwrap_or_else(|| { - self - .pakku_id - .clone() - .unwrap_or_else(|| "unknown".to_string()) - }) + self + .name + .values() + .next() + .map(|s| s.to_owned()) + .or_else(|| self.pakku_id.as_ref().map(|s| s.to_owned())) + .unwrap_or_else(|| "unknown".to_string()) } pub fn matches_input(&self, input: &str) -> bool { @@ -145,10 +146,10 @@ impl Project { pub fn merge(&mut self, other: Self) { // Merge platform identifiers for (platform, id) in other.id { - self.id.entry(platform.clone()).or_insert(id); + self.id.entry(platform).or_insert(id); } for (platform, slug) in other.slug { - self.slug.entry(platform.clone()).or_insert(slug); + self.slug.entry(platform).or_insert(slug); } for (platform, name) in other.name { self.name.entry(platform).or_insert(name); diff --git a/src/rate_limiter.rs b/src/rate_limiter.rs index f5832f8..d3065fd 100644 --- a/src/rate_limiter.rs +++ b/src/rate_limiter.rs @@ -56,18 +56,34 @@ impl RateLimiter { } pub async fn acquire(&self, platform: &str) -> Result<()> { - let config = { + let (rate, burst) = { let inner = self.inner.lock().await; - inner.config.clone() - }; - - let (rate, burst) = match platform.to_lowercase().as_str() { - "modrinth" => (config.modrinth_requests_per_min, config.modrinth_burst), - "curseforge" => { - (config.curseforge_requests_per_min, config.curseforge_burst) - }, - "github" => (config.github_requests_per_min, config.github_burst), - _ => (config.default_requests_per_min, config.default_burst), + match platform.to_lowercase().as_str() { + "modrinth" => { + ( + inner.config.modrinth_requests_per_min, + inner.config.modrinth_burst, + ) + }, + "curseforge" => { + ( + inner.config.curseforge_requests_per_min, + inner.config.curseforge_burst, + ) + }, + "github" => { + ( + inner.config.github_requests_per_min, + inner.config.github_burst, + ) + }, + _ => { + ( + inner.config.default_requests_per_min, + inner.config.default_burst, + ) + }, + } }; let interval = Duration::from_secs(60) / rate.max(1); @@ -76,7 +92,7 @@ impl RateLimiter { let mut inner = self.inner.lock().await; let now = Instant::now(); let platform_requests = - inner.requests.entry(platform.to_string()).or_default(); + inner.requests.entry(platform.to_owned()).or_default(); platform_requests .retain(|t| now.duration_since(*t) < Duration::from_secs(60)); From bc74c24dd5985c752dcf1a05d3b3b5e2de2ac8fc Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 27 Feb 2026 21:55:56 +0300 Subject: [PATCH 14/24] cli/commands: use constants for repeated platform names Signed-off-by: NotAShelf Change-Id: I33c2cb59c73791a16f322e8544161a496a6a6964 --- src/cli/commands/add.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/cli/commands/add.rs b/src/cli/commands/add.rs index 1ea653e..625a6cb 100644 --- a/src/cli/commands/add.rs +++ b/src/cli/commands/add.rs @@ -13,15 +13,18 @@ fn get_loaders(lockfile: &LockFile) -> Vec { pub fn create_all_platforms() -> Result>> { + const MODRINTH: &str = "modrinth"; + const CURSEFORGE: &str = "curseforge"; + let mut platforms = HashMap::new(); - if let Ok(platform) = create_platform("modrinth", None) { - platforms.insert("modrinth".to_string(), platform); + if let Ok(platform) = create_platform(MODRINTH, None) { + platforms.insert(MODRINTH.to_owned(), platform); } if let Ok(platform) = - create_platform("curseforge", std::env::var("CURSEFORGE_API_KEY").ok()) + create_platform(CURSEFORGE, std::env::var("CURSEFORGE_API_KEY").ok()) { - platforms.insert("curseforge".to_string(), platform); + platforms.insert(CURSEFORGE.to_owned(), platform); } Ok(platforms) From c6d60b4459adcba45bfa79b5cf32c785c45753ac Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 27 Feb 2026 22:23:28 +0300 Subject: [PATCH 15/24] model/enums: derive `Ord` for `ReleaseType` to simplify comparisons Signed-off-by: NotAShelf Change-Id: Id47e4e40e015b1212815addbbca238456a6a6964 --- src/model/enums.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/model/enums.rs b/src/model/enums.rs index 2544c97..e56c5da 100644 --- a/src/model/enums.rs +++ b/src/model/enums.rs @@ -103,9 +103,13 @@ impl std::fmt::Display for UpdateStrategy { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, +)] #[serde(rename_all = "lowercase")] pub enum ReleaseType { + // Order matters: Release < Beta < Alpha for sorting (we want Release first) + // But we want reverse order, so we'll use reverse() or handle in comparison Release, Beta, Alpha, From 0fecd1486b03f6394bae3aa0a21e343e53150f72 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 27 Feb 2026 22:26:14 +0300 Subject: [PATCH 16/24] model/lockfile. one less clone! Signed-off-by: NotAShelf Change-Id: I8dbfc511fc07273a69953b03404efc656a6a6964 --- src/model/lockfile.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/model/lockfile.rs b/src/model/lockfile.rs index bc80cee..cdb3a97 100644 --- a/src/model/lockfile.rs +++ b/src/model/lockfile.rs @@ -20,13 +20,15 @@ mod tests { let mut id_map = HashMap::new(); id_map.insert("modrinth".to_string(), pakku_id.to_string()); + let slug_map = name_map.clone(); + Project { pakku_id: Some(pakku_id.to_string()), pakku_links: HashSet::new(), r#type: ProjectType::Mod, side: ProjectSide::Both, - slug: name_map.clone(), - name: name_map.clone(), + slug: slug_map, + name: name_map, id: id_map, update_strategy: UpdateStrategy::Latest, redistributable: true, From d4938c4ae854ea42a5c9519d43594a536d9bdfd0 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 27 Feb 2026 22:26:25 +0300 Subject: [PATCH 17/24] model/project: simplify file selection sorting logic Signed-off-by: NotAShelf Change-Id: I0acf8e690157f1926a2658165592199d6a6a6964 --- src/model/project.rs | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/model/project.rs b/src/model/project.rs index dd3acdc..2f0620c 100644 --- a/src/model/project.rs +++ b/src/model/project.rs @@ -261,20 +261,11 @@ impl Project { ))); } - // Sort by release type (release > beta > alpha) and date - let mut sorted_files = compatible_files.clone(); + // Sort by release type (Release < Beta < Alpha) and date (newest first) + let mut sorted_files = compatible_files.to_vec(); sorted_files.sort_by(|a, b| { - use super::enums::ReleaseType; - let type_order = |rt: &ReleaseType| { - match rt { - ReleaseType::Release => 0, - ReleaseType::Beta => 1, - ReleaseType::Alpha => 2, - } - }; - - type_order(&a.release_type) - .cmp(&type_order(&b.release_type)) + a.release_type + .cmp(&b.release_type) .then_with(|| b.date_published.cmp(&a.date_published)) }); From b0a594e892158646d21e8be2b9cecd0d8e0d33e8 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 27 Feb 2026 22:26:38 +0300 Subject: [PATCH 18/24] cli: fix global `-y`` flag conflicts in `add-prj` and sync commands `AddPrjArgs` had a local `-y` flag that conflicted with the global flag, causing runtime panics. Removed the local field and updated callers to use `global_yes` consistently. The sync command now respects the global `-y` flag by accepting, you guessed it, the `global_yes` parameter. Signed-off-by: NotAShelf Change-Id: I7b7c42fabbca0e363bd18a1d8b6b3bb76a6a6964 --- src/cli.rs | 28 +++------------ src/cli/commands/sync.rs | 30 +++++++---------- src/main.rs | 73 +++++++++++++++++++++++++++++++++++----- 3 files changed, 81 insertions(+), 50 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index fea5e72..8677bdf 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -15,6 +15,10 @@ pub struct Cli { #[clap(short, long, action = clap::ArgAction::Count)] pub verbose: u8, + /// Skip all confirmation prompts (assume yes) + #[clap(short, long, global = true)] + pub yes: bool, + #[clap(subcommand)] pub command: Commands, } @@ -107,10 +111,6 @@ pub struct InitArgs { /// Mod loaders (format: name=version, can be specified multiple times) #[clap(short, long = "loaders", value_delimiter = ',')] pub loaders: Option>, - - /// Skip interactive prompts (use defaults) - #[clap(short, long)] - pub yes: bool, } #[derive(Args)] @@ -121,10 +121,6 @@ pub struct ImportArgs { /// Resolve dependencies #[clap(short = 'D', long = "deps")] pub deps: bool, - - /// Skip confirmation prompts - #[clap(short, long)] - pub yes: bool, } #[derive(Args)] @@ -144,10 +140,6 @@ pub struct AddArgs { /// Update if already exists #[clap(short, long)] pub update: bool, - - /// Skip confirmation prompts - #[clap(short, long)] - pub yes: bool, } #[derive(Args)] @@ -195,10 +187,6 @@ pub struct AddPrjArgs { /// Skip resolving dependencies #[clap(short = 'D', long = "no-deps")] pub no_deps: bool, - - /// Skip confirmation prompts - #[clap(short, long)] - pub yes: bool, } #[derive(Args)] @@ -211,10 +199,6 @@ pub struct RmArgs { #[clap(short = 'a', long)] pub all: bool, - /// Skip confirmation prompt - #[clap(short, long)] - pub yes: bool, - /// Skip removing dependent projects #[clap(short = 'D', long = "no-deps")] pub no_deps: bool, @@ -229,10 +213,6 @@ pub struct UpdateArgs { /// Update all projects #[arg(short, long)] pub all: bool, - - /// Skip confirmation prompts - #[arg(short, long)] - pub yes: bool, } #[derive(Args)] diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index c5404cf..e806cfc 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -1,7 +1,6 @@ use std::{ collections::{HashMap, HashSet}, fs, - io::{self, Write}, path::{Path, PathBuf}, }; @@ -22,6 +21,7 @@ enum SyncChange { pub async fn execute( args: SyncArgs, + global_yes: bool, lockfile_path: &Path, config_path: &Path, ) -> Result<()> { @@ -66,7 +66,11 @@ pub async fn execute( for (file_path, _) in &additions { spinner .set_message(format!("Processing addition: {}", file_path.display())); - if prompt_user(&format!("Add {} to lockfile?", file_path.display()))? { + if crate::ui_utils::prompt_yes_no( + &format!("Add {} to lockfile?", file_path.display()), + false, + global_yes, + )? { add_file_to_lockfile(&mut lockfile, file_path, &config).await?; } } @@ -87,7 +91,11 @@ pub async fn execute( .or(project.pakku_id.as_deref()) .unwrap_or("unknown"); spinner.set_message(format!("Processing removal: {name}")); - if prompt_user(&format!("Remove {name} from lockfile?"))? { + if crate::ui_utils::prompt_yes_no( + &format!("Remove {name} from lockfile?"), + false, + global_yes, + )? { lockfile .remove_project(pakku_id) .ok_or_else(|| PakkerError::ProjectNotFound(pakku_id.clone()))?; @@ -174,7 +182,7 @@ async fn add_file_to_lockfile( _config: &Config, ) -> Result<()> { // Try to identify the file by hash lookup - let _modrinth = ModrinthPlatform::new(); + let modrinth = ModrinthPlatform::new(); let curseforge = CurseForgePlatform::new(None); // Compute file hash @@ -186,7 +194,7 @@ async fn add_file_to_lockfile( let hash = format!("{:x}", hasher.finalize()); // Try Modrinth first (SHA-1 hash) - if let Ok(Some(project)) = _modrinth.lookup_by_hash(&hash).await { + if let Ok(Some(project)) = modrinth.lookup_by_hash(&hash).await { lockfile.add_project(project); println!("✓ Added {} (from Modrinth)", file_path.display()); return Ok(()); @@ -202,15 +210,3 @@ async fn add_file_to_lockfile( println!("⚠ Could not identify {}, skipping", file_path.display()); Ok(()) } - -fn prompt_user(message: &str) -> Result { - print!("{message} [y/N] "); - io::stdout().flush().map_err(PakkerError::IoError)?; - - let mut input = String::new(); - io::stdin() - .read_line(&mut input) - .map_err(PakkerError::IoError)?; - - Ok(input.trim().eq_ignore_ascii_case("y")) -} diff --git a/src/main.rs b/src/main.rs index 95f0aed..a322b4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,12 +18,31 @@ mod resolver; mod ui_utils; mod utils; -use std::path::PathBuf; +use std::{env, path::PathBuf}; use clap::Parser; use cli::{Cli, Commands}; use error::PakkerError; +/// Search for pakker-lock.json in current directory and parent directories +/// Returns the directory containing pakker-lock.json, or None if not found +fn find_working_directory() -> Option { + let mut current_dir = env::current_dir().ok()?; + + loop { + let lockfile = current_dir.join("pakker-lock.json"); + if lockfile.exists() { + return Some(current_dir); + } + + // Try parent directory + if !current_dir.pop() { + // Reached filesystem root + return None; + } + } +} + #[tokio::main] async fn main() -> Result<(), PakkerError> { let cli = Cli::parse(); @@ -43,19 +62,41 @@ async fn main() -> Result<(), PakkerError> { .format_module_path(false) .init(); - let working_dir = PathBuf::from("."); + // Search for pakker-lock.json in current directory and parent directories + let working_dir = + find_working_directory().unwrap_or_else(|| PathBuf::from(".")); let lockfile_path = working_dir.join("pakker-lock.json"); let config_path = working_dir.join("pakker.json"); + let global_yes = cli.yes; + match cli.command { Commands::Init(args) => { - cli::commands::init::execute(args, &lockfile_path, &config_path).await + cli::commands::init::execute( + args, + global_yes, + &lockfile_path, + &config_path, + ) + .await }, Commands::Import(args) => { - cli::commands::import::execute(args, &lockfile_path, &config_path).await + cli::commands::import::execute( + args, + global_yes, + &lockfile_path, + &config_path, + ) + .await }, Commands::Add(args) => { - cli::commands::add::execute(args, &lockfile_path, &config_path).await + cli::commands::add::execute( + args, + global_yes, + &lockfile_path, + &config_path, + ) + .await }, Commands::AddPrj(args) => { cli::commands::add_prj::execute( @@ -70,17 +111,24 @@ async fn main() -> Result<(), PakkerError> { args.aliases, args.export, args.no_deps, - args.yes, + global_yes, &lockfile_path, &config_path, ) .await }, Commands::Rm(args) => { - cli::commands::rm::execute(args, &lockfile_path, &config_path).await + cli::commands::rm::execute(args, global_yes, &lockfile_path, &config_path) + .await }, Commands::Update(args) => { - cli::commands::update::execute(args, &lockfile_path, &config_path).await + cli::commands::update::execute( + args, + global_yes, + &lockfile_path, + &config_path, + ) + .await }, Commands::Ls(args) => cli::commands::ls::execute(args, &lockfile_path), Commands::Set(args) => { @@ -95,7 +143,13 @@ async fn main() -> Result<(), PakkerError> { cli::commands::fetch::execute(args, &lockfile_path, &config_path).await }, Commands::Sync(args) => { - cli::commands::sync::execute(args, &lockfile_path, &config_path).await + cli::commands::sync::execute( + args, + global_yes, + &lockfile_path, + &config_path, + ) + .await }, Commands::Export(args) => { cli::commands::export::execute(args, &lockfile_path, &config_path).await @@ -107,6 +161,7 @@ async fn main() -> Result<(), PakkerError> { Commands::Status(args) => { cli::commands::status::execute( args.parallel, + global_yes, &lockfile_path, &config_path, ) From f4287de7958c895cc6c07f7b5a1be831298d266b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 28 Feb 2026 23:08:26 +0300 Subject: [PATCH 19/24] treewide: remove dead code Also deletes some dead_code annotations from functions that are *actually used*. Signed-off-by: NotAShelf Change-Id: Ic815cacc93c464078ead1674e7523d8b6a6a6964 --- src/cli/commands/status.rs | 16 ++------ src/ui_utils.rs | 49 +++++++++++++----------- src/utils/hash.rs | 77 -------------------------------------- 3 files changed, 30 insertions(+), 112 deletions(-) diff --git a/src/cli/commands/status.rs b/src/cli/commands/status.rs index 6b50ed6..1e157a8 100644 --- a/src/cli/commands/status.rs +++ b/src/cli/commands/status.rs @@ -13,6 +13,7 @@ use crate::{ pub async fn execute( parallel: bool, + skip_prompts: bool, lockfile_path: &Path, config_path: &Path, ) -> Result<()> { @@ -77,15 +78,15 @@ pub async fn execute( // Prompt to update if there are updates available if !updates.is_empty() { println!(); - if crate::ui_utils::prompt_yes_no("Update now?", false)? { + if crate::ui_utils::prompt_yes_no("Update now?", false, skip_prompts)? { // Call update command programmatically (update all projects) let update_args = crate::cli::UpdateArgs { inputs: vec![], all: true, - yes: true, // Auto-yes for status command }; crate::cli::commands::update::execute( update_args, + true, // Auto-yes for status command lockfile_path, config_path, ) @@ -380,17 +381,6 @@ fn display_update_results(updates: &[ProjectUpdate]) { ); } -#[allow(dead_code)] -fn get_project_display_name(project: &Project) -> String { - project - .name - .values() - .next() - .or_else(|| project.slug.values().next()) - .cloned() - .unwrap_or_else(|| "Unknown".to_string()) -} - fn get_api_key(platform: &str) -> Option { match platform { "modrinth" => std::env::var("MODRINTH_TOKEN").ok(), diff --git a/src/ui_utils.rs b/src/ui_utils.rs index 62589b5..f293256 100644 --- a/src/ui_utils.rs +++ b/src/ui_utils.rs @@ -2,7 +2,7 @@ use std::io; -use dialoguer::{Confirm, Input, MultiSelect, Select, theme::ColorfulTheme}; +use dialoguer::{Confirm, Input, Select, theme::ColorfulTheme}; /// Creates a terminal hyperlink using OSC 8 escape sequence /// Format: \x1b]8;;\x1b\\\x1b]8;;\x1b\\ @@ -12,7 +12,16 @@ pub fn hyperlink(url: &str, text: &str) -> String { /// Prompts user with a yes/no question /// Returns true for yes, false for no -pub fn prompt_yes_no(question: &str, default: bool) -> io::Result { +/// If `skip_prompts` is true, returns the default value without prompting +pub fn prompt_yes_no( + question: &str, + default: bool, + skip_prompts: bool, +) -> io::Result { + if skip_prompts { + return Ok(default); + } + Confirm::with_theme(&ColorfulTheme::default()) .with_prompt(question) .default(default) @@ -22,7 +31,6 @@ pub fn prompt_yes_no(question: &str, default: bool) -> io::Result { /// Prompts user to select from a list of options /// Returns the index of the selected option -#[allow(dead_code)] pub fn prompt_select(question: &str, options: &[&str]) -> io::Result { Select::with_theme(&ColorfulTheme::default()) .with_prompt(question) @@ -32,28 +40,12 @@ pub fn prompt_select(question: &str, options: &[&str]) -> io::Result { .map_err(io::Error::other) } -/// Prompts user to select multiple items from a list -/// Returns the indices of the selected options -#[allow(dead_code)] -pub fn prompt_multi_select( - question: &str, - options: &[&str], -) -> io::Result> { - MultiSelect::with_theme(&ColorfulTheme::default()) - .with_prompt(question) - .items(options) - .interact() - .map_err(io::Error::other) -} - /// Creates a formatted project URL for Modrinth -#[allow(dead_code)] pub fn modrinth_project_url(slug: &str) -> String { format!("https://modrinth.com/mod/{slug}") } /// Creates a formatted project URL for `CurseForge` -#[allow(dead_code)] pub fn curseforge_project_url(project_id: &str) -> String { format!("https://www.curseforge.com/minecraft/mc-mods/{project_id}") } @@ -118,16 +110,22 @@ pub fn suggest_similar<'a>( /// Prompt user if they meant a similar project name. /// Returns `Some(suggested_name)` if user confirms, None otherwise. +/// If `skip_prompts` is true, automatically accepts the first suggestion. pub fn prompt_typo_suggestion( input: &str, candidates: &[String], + skip_prompts: bool, ) -> io::Result> { // Use a max distance based on input length for reasonable suggestions let max_distance = (input.len() / 2).clamp(2, 4); let suggestions = suggest_similar(input, candidates, max_distance); if let Some(first_suggestion) = suggestions.first() - && prompt_yes_no(&format!("Did you mean '{first_suggestion}'?"), true)? + && prompt_yes_no( + &format!("Did you mean '{first_suggestion}'?"), + true, + skip_prompts, + )? { return Ok(Some((*first_suggestion).to_string())); } @@ -164,7 +162,14 @@ pub fn prompt_input_optional(prompt: &str) -> io::Result> { /// Prompt for `CurseForge` API key when authentication fails. /// Returns the API key if provided, None if cancelled. -pub fn prompt_curseforge_api_key() -> io::Result> { +/// If `skip_prompts` is true, returns None immediately. +pub fn prompt_curseforge_api_key( + skip_prompts: bool, +) -> io::Result> { + if skip_prompts { + return Ok(None); + } + use dialoguer::Password; println!(); @@ -172,7 +177,7 @@ pub fn prompt_curseforge_api_key() -> io::Result> { println!("Get your API key from: https://console.curseforge.com/"); println!(); - if !prompt_yes_no("Would you like to enter your API key now?", true)? { + if !prompt_yes_no("Would you like to enter your API key now?", true, false)? { return Ok(None); } diff --git a/src/utils/hash.rs b/src/utils/hash.rs index 88abdde..d440b0e 100644 --- a/src/utils/hash.rs +++ b/src/utils/hash.rs @@ -10,58 +10,6 @@ use sha2::{Sha256, Sha512}; use crate::error::{PakkerError, Result}; -/// Compute Murmur2 hash (32-bit) for `CurseForge` fingerprinting -#[allow(dead_code)] -pub fn compute_murmur2_hash(data: &[u8]) -> u32 { - murmur2_hash(data, 1) -} - -/// Murmur2 hash implementation -#[allow(dead_code)] -fn murmur2_hash(data: &[u8], seed: u32) -> u32 { - const M: u32 = 0x5BD1E995; - const R: i32 = 24; - - let mut h: u32 = seed ^ (data.len() as u32); - let mut chunks = data.chunks_exact(4); - - for chunk in chunks.by_ref() { - let mut k = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); - k = k.wrapping_mul(M); - k ^= k >> R; - k = k.wrapping_mul(M); - - h = h.wrapping_mul(M); - h ^= k; - } - - let remainder = chunks.remainder(); - match remainder.len() { - 3 => { - h ^= u32::from(remainder[2]) << 16; - h ^= u32::from(remainder[1]) << 8; - h ^= u32::from(remainder[0]); - h = h.wrapping_mul(M); - }, - 2 => { - h ^= u32::from(remainder[1]) << 8; - h ^= u32::from(remainder[0]); - h = h.wrapping_mul(M); - }, - 1 => { - h ^= u32::from(remainder[0]); - h = h.wrapping_mul(M); - }, - _ => {}, - } - - h ^= h >> 13; - h = h.wrapping_mul(M); - h ^= h >> 15; - - h -} - /// Compute SHA1 hash of a file pub fn compute_sha1>(path: P) -> Result { let file = File::open(path)?; @@ -167,31 +115,6 @@ pub fn verify_hash>( mod tests { use super::*; - #[test] - fn test_murmur2_hash_deterministic() { - let data = b"hello world"; - let hash1 = compute_murmur2_hash(data); - let hash2 = compute_murmur2_hash(data); - assert_eq!(hash1, hash2, "Murmur2 hash must be deterministic"); - } - - #[test] - fn test_murmur2_hash_empty() { - let data = b""; - let hash = compute_murmur2_hash(data); - assert_ne!(hash, 0, "Empty data should produce a non-zero hash"); - } - - #[test] - fn test_murmur2_hash_different_inputs() { - let hash1 = compute_murmur2_hash(b"hello"); - let hash2 = compute_murmur2_hash(b"world"); - assert_ne!( - hash1, hash2, - "Different inputs should produce different hashes" - ); - } - #[test] fn test_sha256_bytes_deterministic() { let data = b"test data"; From 0b5882b1e1a1bd541e28dddd71da980e979dc388 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 28 Feb 2026 23:09:34 +0300 Subject: [PATCH 20/24] cli/commands: use `create_all_platforms` to reduce duplication in update cmd Signed-off-by: NotAShelf Change-Id: I00d3029de7c13a57cefb1b6eaae9f1606a6a6964 --- src/cli/commands/update.rs | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/cli/commands/update.rs b/src/cli/commands/update.rs index f33caf2..785219b 100644 --- a/src/cli/commands/update.rs +++ b/src/cli/commands/update.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, path::Path}; +use std::path::Path; use indicatif::{ProgressBar, ProgressStyle}; @@ -6,15 +6,16 @@ use crate::{ cli::UpdateArgs, error::{MultiError, PakkerError}, model::{Config, LockFile, UpdateStrategy}, - platform::create_platform, ui_utils::{prompt_select, prompt_typo_suggestion, prompt_yes_no}, }; pub async fn execute( args: UpdateArgs, + global_yes: bool, lockfile_path: &Path, config_path: &Path, ) -> Result<(), PakkerError> { + let skip_prompts = global_yes; // Load expects directory path, so get parent directory let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new(".")); let config_dir = config_path.parent().unwrap_or(Path::new(".")); @@ -23,15 +24,7 @@ pub async fn execute( let _config = Config::load(config_dir)?; // Create platforms - let mut platforms = HashMap::new(); - if let Ok(platform) = create_platform("modrinth", None) { - platforms.insert("modrinth".to_string(), platform); - } - if let Ok(platform) = - create_platform("curseforge", std::env::var("CURSEFORGE_API_KEY").ok()) - { - platforms.insert("curseforge".to_string(), platform); - } + let platforms = super::add::create_all_platforms()?; // Collect all known project identifiers for typo suggestions let all_slugs: Vec = lockfile @@ -63,7 +56,8 @@ pub async fn execute( indices.push(idx); } else { // Try typo suggestion - if let Ok(Some(suggestion)) = prompt_typo_suggestion(input, &all_slugs) + if let Ok(Some(suggestion)) = + prompt_typo_suggestion(input, &all_slugs, skip_prompts) && let Some((idx, _)) = lockfile .projects .iter() @@ -159,17 +153,18 @@ pub async fn execute( } else { // Interactive confirmation and version selection if not using --yes // flag - let mut should_update = args.yes || args.all; + let mut should_update = skip_prompts || args.all; let mut selected_idx: Option = None; - if !args.yes && !args.all { + if !skip_prompts && !args.all { pb.suspend(|| { // First, confirm the update let prompt_msg = format!( "Update '{project_name}' from {old_file_name} to \ {new_file_name}?" ); - should_update = prompt_yes_no(&prompt_msg, true).unwrap_or(false); + should_update = + prompt_yes_no(&prompt_msg, true, skip_prompts).unwrap_or(false); // If confirmed and multiple versions available, offer selection if should_update && updated_project.files.len() > 1 { From 8478c914b516bdb81eb02d3ad600260fbbae1572 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 28 Feb 2026 23:10:02 +0300 Subject: [PATCH 21/24] model/lockfile: update tests to use `get_project` Signed-off-by: NotAShelf Change-Id: I2163215bc069431e3d6d53c9c14dd15c6a6a6964 --- src/model/lockfile.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/model/lockfile.rs b/src/model/lockfile.rs index cdb3a97..dc10595 100644 --- a/src/model/lockfile.rs +++ b/src/model/lockfile.rs @@ -76,11 +76,11 @@ mod tests { lockfile.add_project(create_test_project("test-id", "test-slug")); - let found = lockfile.find_project("test-id"); + let found = lockfile.get_project("test-id"); assert!(found.is_some()); assert_eq!(found.unwrap().pakku_id, Some("test-id".to_string())); - let not_found = lockfile.find_project("nonexistent"); + let not_found = lockfile.get_project("nonexistent"); assert!(not_found.is_none()); } @@ -604,10 +604,6 @@ impl LockFile { } } - pub fn find_project(&self, pakku_id: &str) -> Option<&Project> { - self.get_project(pakku_id) - } - pub fn find_project_mut(&mut self, pakku_id: &str) -> Option<&mut Project> { self .projects From a89184a3582efe9e62ba0e68d2fe611ee77f8352 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 28 Feb 2026 23:40:34 +0300 Subject: [PATCH 22/24] model: add `file_count_preference` for multi-file selection support Signed-off-by: NotAShelf Change-Id: Ia27c829dbcc21a7fcfc8e6f67f9e33276a6a6964 --- src/cli/tests.rs | 2 ++ src/export/rules.rs | 5 +++++ src/model/config.rs | 7 +++++++ src/model/project.rs | 10 +++++----- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/cli/tests.rs b/src/cli/tests.rs index 0716df7..e116088 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -171,6 +171,7 @@ mod tests { projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; assert!(config.export_server_side_projects_to_client.is_none()); } @@ -220,6 +221,7 @@ mod tests { projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; let json = serde_json::to_string_pretty(&config).unwrap(); diff --git a/src/export/rules.rs b/src/export/rules.rs index 582a702..bfdf040 100644 --- a/src/export/rules.rs +++ b/src/export/rules.rs @@ -1232,6 +1232,7 @@ mod tests { projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }, profile_config, export_path: PathBuf::from("/tmp/export"), @@ -1362,6 +1363,7 @@ mod tests { projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; assert_eq!(get_project_type_dir(&ProjectType::Mod, &config), "mods"); @@ -1398,6 +1400,7 @@ mod tests { projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; assert_eq!( @@ -1462,6 +1465,7 @@ mod tests { projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; let mut context = create_test_context(None); @@ -1492,6 +1496,7 @@ mod tests { projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; let mut context = create_test_context(None); diff --git a/src/model/config.rs b/src/model/config.rs index 83172ae..c65a588 100644 --- a/src/model/config.rs +++ b/src/model/config.rs @@ -64,6 +64,9 @@ pub struct Config { rename = "exportServerSideProjectsToClient" )] pub export_server_side_projects_to_client: Option, + /// Number of files to select per project (defaults to 1) + #[serde(skip_serializing_if = "Option::is_none")] + pub file_count_preference: Option, } impl Default for Config { @@ -80,6 +83,7 @@ impl Default for Config { projects: Some(HashMap::new()), export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, } } } @@ -145,6 +149,7 @@ impl Config { projects: Some(pakku.projects), export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }) }, Err(e) => Err(PakkerError::InvalidConfigFile(e.to_string())), @@ -203,6 +208,7 @@ mod tests { projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; assert_eq!(config.name, "test-pack"); assert_eq!(config.version, "1.0.0"); @@ -224,6 +230,7 @@ mod tests { projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; config.description = Some("A test modpack".to_string()); config.author = Some("Test Author".to_string()); diff --git a/src/model/project.rs b/src/model/project.rs index 2f0620c..f4da4aa 100644 --- a/src/model/project.rs +++ b/src/model/project.rs @@ -246,6 +246,7 @@ impl Project { &mut self, mc_versions: &[String], loaders: &[String], + file_count: Option, ) -> crate::error::Result<()> { // Filter compatible files let compatible_files: Vec<_> = self @@ -269,10 +270,9 @@ impl Project { .then_with(|| b.date_published.cmp(&a.date_published)) }); - // Keep only the best file - if let Some(best_file) = sorted_files.first() { - self.files = vec![(*best_file).clone()]; - } + // Keep the specified number of files (default to 1 if not specified) + let count = file_count.unwrap_or(1); + self.files = sorted_files.into_iter().take(count).cloned().collect(); Ok(()) } @@ -531,7 +531,7 @@ mod tests { let lockfile_mc = vec!["1.20.1".to_string()]; let lockfile_loaders = vec!["fabric".to_string()]; - let result = project.select_file(&lockfile_mc, &lockfile_loaders); + let result = project.select_file(&lockfile_mc, &lockfile_loaders, None); assert!(result.is_ok()); } From f2af2fbbe46dfef012739e117b0f4e0374bab844 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 28 Feb 2026 23:40:42 +0300 Subject: [PATCH 23/24] cli/commands: update all commands to use `global_yes` parameter Signed-off-by: NotAShelf Change-Id: I4d95a425f2bed75aed1b5233adf1a3646a6a6964 --- src/cli/commands/add.rs | 11 +++++++---- src/cli/commands/add_prj.rs | 6 +++--- src/cli/commands/cfg_prj.rs | 2 +- src/cli/commands/import.rs | 11 +++++++++-- src/cli/commands/init.rs | 16 ++++++++++++---- src/cli/commands/rm.rs | 10 +++++++--- 6 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/cli/commands/add.rs b/src/cli/commands/add.rs index 625a6cb..9eb3780 100644 --- a/src/cli/commands/add.rs +++ b/src/cli/commands/add.rs @@ -57,9 +57,11 @@ use crate::{cli::AddArgs, model::fork::LocalConfig}; pub async fn execute( args: AddArgs, + global_yes: bool, lockfile_path: &Path, config_path: &Path, ) -> Result<()> { + let skip_prompts = global_yes; log::info!("Adding projects: {:?}", args.inputs); // Load lockfile @@ -187,9 +189,9 @@ pub async fn execute( } // Prompt for confirmation unless --yes flag is set - if !args.yes { + if !skip_prompts { let prompt_msg = format!("Add project '{}'?", project.get_name()); - if !crate::ui_utils::prompt_yes_no(&prompt_msg, true)? { + if !crate::ui_utils::prompt_yes_no(&prompt_msg, true, skip_prompts)? { log::info!("Skipping project: {}", project.get_name()); continue; } @@ -213,13 +215,14 @@ pub async fn execute( && !all_new_projects.iter().any(|p| p.pakku_id == dep.pakku_id) { // Prompt user for confirmation unless --yes flag is set - if !args.yes { + if !skip_prompts { let prompt_msg = format!( "Add dependency '{}' required by '{}'?", dep.get_name(), project.get_name() ); - if !crate::ui_utils::prompt_yes_no(&prompt_msg, true)? { + if !crate::ui_utils::prompt_yes_no(&prompt_msg, true, skip_prompts)? + { log::info!("Skipping dependency: {}", dep.get_name()); continue; } diff --git a/src/cli/commands/add_prj.rs b/src/cli/commands/add_prj.rs index f111331..fd3166c 100644 --- a/src/cli/commands/add_prj.rs +++ b/src/cli/commands/add_prj.rs @@ -232,7 +232,7 @@ pub async fn execute( "Project '{existing_name}' already exists. Replace with \ '{project_name}'?" ); - if !crate::ui_utils::prompt_yes_no(&prompt_msg, false)? { + if !crate::ui_utils::prompt_yes_no(&prompt_msg, false, yes)? { log::info!("Operation cancelled by user"); return Ok(()); } @@ -244,7 +244,7 @@ pub async fn execute( } else { if !yes { let prompt_msg = format!("Add project '{project_name}'?"); - if !crate::ui_utils::prompt_yes_no(&prompt_msg, true)? { + if !crate::ui_utils::prompt_yes_no(&prompt_msg, true, yes)? { log::info!("Operation cancelled by user"); return Ok(()); } @@ -283,7 +283,7 @@ pub async fn execute( if !yes { let prompt_msg = format!("Add dependency '{dep_name}' required by '{project_name}'?"); - if !crate::ui_utils::prompt_yes_no(&prompt_msg, true)? { + if !crate::ui_utils::prompt_yes_no(&prompt_msg, true, yes)? { log::info!("Skipping dependency: {dep_name}"); continue; } diff --git a/src/cli/commands/cfg_prj.rs b/src/cli/commands/cfg_prj.rs index 3ad7346..0c9018f 100644 --- a/src/cli/commands/cfg_prj.rs +++ b/src/cli/commands/cfg_prj.rs @@ -32,7 +32,7 @@ pub fn execute( // Find the project in lockfile to get its pakku_id // Try multiple lookup strategies: pakku_id first, then slug, then name let found_project = lockfile - .find_project(&project) + .get_project(&project) .or_else(|| { // Try to find by slug on any platform lockfile diff --git a/src/cli/commands/import.rs b/src/cli/commands/import.rs index c9a20da..12f3113 100644 --- a/src/cli/commands/import.rs +++ b/src/cli/commands/import.rs @@ -9,9 +9,11 @@ use crate::{ pub async fn execute( args: ImportArgs, + global_yes: bool, lockfile_path: &Path, config_path: &Path, ) -> Result<()> { + let skip_prompts = global_yes; log::info!("Importing modpack from {}", args.file); log::info!( "Dependency resolution: {}", @@ -27,7 +29,7 @@ pub async fn execute( } // Check if lockfile or config already exist - if (lockfile_path.exists() || config_path.exists()) && !args.yes { + if (lockfile_path.exists() || config_path.exists()) && !skip_prompts { let msg = if lockfile_path.exists() && config_path.exists() { "Both pakku-lock.json and pakku.json exist. Importing will overwrite \ them. Continue?" @@ -37,7 +39,7 @@ pub async fn execute( "pakku.json exists. Importing will overwrite it. Continue?" }; - if !prompt_yes_no(msg, false)? { + if !prompt_yes_no(msg, false, skip_prompts)? { log::info!("Import cancelled by user"); return Ok(()); } @@ -146,6 +148,7 @@ async fn import_modrinth( if let Err(e) = project.select_file( &lockfile.mc_versions, std::slice::from_ref(&loader.0), + None, // Use default (1 file) during import ) { log::warn!( "Failed to select file for {}: {}", @@ -185,6 +188,7 @@ async fn import_modrinth( projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; // Save files using provided paths @@ -314,6 +318,7 @@ async fn import_curseforge( if let Err(e) = project.select_file( &lockfile.mc_versions, &loaders.keys().cloned().collect::>(), + None, // Use default (1 file) during import ) { log::warn!( "Failed to select file for {}: {}", @@ -328,6 +333,7 @@ async fn import_curseforge( if let Err(e) = project.select_file( &lockfile.mc_versions, &loaders.keys().cloned().collect::>(), + None, // Use default (1 file) during import ) { log::warn!( "Failed to select file for {}: {}", @@ -368,6 +374,7 @@ async fn import_curseforge( projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; // Save files using provided paths diff --git a/src/cli/commands/init.rs b/src/cli/commands/init.rs index d0da1ed..13858ef 100644 --- a/src/cli/commands/init.rs +++ b/src/cli/commands/init.rs @@ -14,9 +14,12 @@ use crate::{ pub async fn execute( args: InitArgs, + global_yes: bool, lockfile_path: &Path, config_path: &Path, ) -> Result<(), PakkerError> { + let skip_prompts = global_yes; + if lockfile_path.exists() { return Err(PakkerError::AlreadyExists( "Lock file already exists".into(), @@ -24,7 +27,7 @@ pub async fn execute( } // Interactive mode: prompt for values not provided via CLI and --yes not set - let is_interactive = !args.yes && args.name.is_none(); + let is_interactive = !skip_prompts && args.name.is_none(); // Get modpack name let name = if let Some(name) = args.name.clone() { @@ -137,6 +140,7 @@ pub async fn execute( projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; let config_dir = config_path.parent().unwrap_or(Path::new(".")); @@ -164,9 +168,13 @@ pub async fn execute( if !has_cf_key { println!(); - if prompt_yes_no("Would you like to set up CurseForge API key now?", true) - .map_err(|e| PakkerError::InvalidInput(e.to_string()))? - && let Ok(Some(api_key)) = prompt_curseforge_api_key() + if prompt_yes_no( + "Would you like to set up CurseForge API key now?", + true, + skip_prompts, + ) + .map_err(|e| PakkerError::InvalidInput(e.to_string()))? + && let Ok(Some(api_key)) = prompt_curseforge_api_key(skip_prompts) { // Save to credentials file let creds_path = std::env::var("HOME").map_or_else( diff --git a/src/cli/commands/rm.rs b/src/cli/commands/rm.rs index a147d66..5189e59 100644 --- a/src/cli/commands/rm.rs +++ b/src/cli/commands/rm.rs @@ -9,9 +9,11 @@ use crate::{ pub async fn execute( args: RmArgs, + global_yes: bool, lockfile_path: &Path, _config_path: &Path, ) -> Result<()> { + let skip_prompts = global_yes; // Load expects directory path, so get parent directory let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new(".")); let mut lockfile = LockFile::load(lockfile_dir)?; @@ -79,7 +81,9 @@ pub async fn execute( resolved_inputs.push(input.clone()); } else if !args.all { // Try typo suggestion - if let Ok(Some(suggestion)) = prompt_typo_suggestion(input, &all_slugs) { + if let Ok(Some(suggestion)) = + prompt_typo_suggestion(input, &all_slugs, skip_prompts) + { log::info!("Using suggested project: {suggestion}"); resolved_inputs.push(suggestion); } else { @@ -111,13 +115,13 @@ pub async fn execute( // Ask for confirmation unless --yes flag is provided or --all with no // projects - if !args.yes { + if !skip_prompts { println!("The following projects will be removed:"); for name in &projects_to_remove { println!(" - {name}"); } - if !prompt_yes_no("Do you want to continue?", false)? { + if !prompt_yes_no("Do you want to continue?", false, skip_prompts)? { println!("Removal cancelled."); return Ok(()); } From 83343bc3dd938075a72dec3c4949187f20947ca0 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 28 Feb 2026 23:41:04 +0300 Subject: [PATCH 24/24] utils: reorganize module structure Signed-off-by: NotAShelf Change-Id: I5b51e349ea67e27170e3a3ebe6b1d3fe6a6a6964 --- src/{utils.rs => utils/mod.rs} | 1 - src/utils/prompt.rs | 56 ---------------------------------- 2 files changed, 57 deletions(-) rename src/{utils.rs => utils/mod.rs} (84%) delete mode 100644 src/utils/prompt.rs diff --git a/src/utils.rs b/src/utils/mod.rs similarity index 84% rename from src/utils.rs rename to src/utils/mod.rs index 582c559..86947cb 100644 --- a/src/utils.rs +++ b/src/utils/mod.rs @@ -1,6 +1,5 @@ pub mod hash; pub mod id; -pub mod prompt; pub use hash::verify_hash; pub use id::generate_pakku_id; diff --git a/src/utils/prompt.rs b/src/utils/prompt.rs deleted file mode 100644 index aa6c1dc..0000000 --- a/src/utils/prompt.rs +++ /dev/null @@ -1,56 +0,0 @@ -use std::io::{self, Write}; - -use crate::error::Result; - -#[allow(dead_code)] -pub fn prompt_user(message: &str) -> Result { - print!("{message}"); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - Ok(input.trim().to_string()) -} - -#[allow(dead_code)] -pub fn prompt_select(message: &str, options: &[String]) -> Result { - println!("{message}"); - for (i, option) in options.iter().enumerate() { - println!(" {}. {}", i + 1, option); - } - - loop { - print!("Select (1-{}): ", options.len()); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - if let Ok(choice) = input.trim().parse::() - && choice > 0 - && choice <= options.len() - { - return Ok(choice - 1); - } - - println!("Invalid selection. Please try again."); - } -} - -#[allow(dead_code)] -pub fn prompt_confirm(message: &str) -> Result { - print!("{message} (y/n): "); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - let answer = input.trim().to_lowercase(); - Ok(answer == "y" || answer == "yes") -} - -#[allow(dead_code)] -pub fn confirm(message: &str) -> Result { - prompt_confirm(message) -}