From 1c08e00ccf5488465d4841443480e53ddddef7e5 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 28 Feb 2026 23:41:17 +0300 Subject: [PATCH 01/18] utils/flexver: add flexver comparator Signed-off-by: NotAShelf Change-Id: I79b8d3745a8754619f810de1bac8b66f6a6a6964 --- src/utils/flexver.rs | 326 +++++++++++++++++++++++++++++++++++++++++++ src/utils/mod.rs | 2 + 2 files changed, 328 insertions(+) create mode 100644 src/utils/flexver.rs diff --git a/src/utils/flexver.rs b/src/utils/flexver.rs new file mode 100644 index 0000000..7290776 --- /dev/null +++ b/src/utils/flexver.rs @@ -0,0 +1,326 @@ +// FlexVer - Flexible Version Comparison +// +// This implementation is based on the original implementation of the +// `flexver-rs` crate, which no longer appears to be maintained. +// +// See: +// +// +// This implementation provides semver-like version comparison with support for: +// +// - Flexible version string parsing (not strict semver) +// - Pre-release handling (parts starting with `-`) +// - Build metadata stripping (parts after `+`) +// - Numerical vs lexical comparison + +use std::{ + cmp::Ordering::{self, Equal, Greater, Less}, + collections::VecDeque, +}; + +/// Type of version component for sorting purposes +#[derive(Debug, Clone, PartialEq)] +enum SortingType { + /// A numeric component with both i64 value and string representation + Numerical(i64, String), + + /// A lexical string component + Lexical(String), + + /// A semver pre-release component (starting with `-`) + SemverPrerelease(String), +} + +impl SortingType { + fn into_string(self) -> String { + match self { + Self::Numerical(_, s) | Self::Lexical(s) | Self::SemverPrerelease(s) => s, + } + } +} + +fn is_semver_prerelease(s: &str) -> bool { + s.len() > 1 && s.starts_with('-') +} + +/// Decompose a version string into its component parts +fn decompose(str_in: &str) -> VecDeque { + if str_in.is_empty() { + return VecDeque::new(); + } + + // Strip build metadata (after `+`) + let s = if let Some((left, _)) = str_in.split_once('+') { + left + } else { + str_in + }; + + let mut out: VecDeque = VecDeque::new(); + let mut current = String::new(); + + let mut currently_numeric = s.starts_with(|c: char| c.is_ascii_digit()); + let mut skip = s.starts_with('-'); + + fn handle_split( + current: &str, + c: Option<&char>, + currently_numeric: bool, + ) -> Option { + let numeric = if let Some(c) = c { + c.is_ascii_digit() + } else { + false + }; + + use SortingType::*; + + if currently_numeric { + if numeric { + return None; + } else { + return Some(Numerical( + current.parse::().unwrap(), + current.to_owned(), + )); + } + } + + if !(numeric || c == Some(&'-') || c.is_none()) { + return None; + } + + if is_semver_prerelease(current) { + if c == Some(&'-') { + // Pre-releases can have multiple dashes + None + } else { + Some(SemverPrerelease(current.to_owned())) + } + } else { + Some(Lexical(current.to_owned())) + } + } + + for c in s.chars() { + if let Some(part) = handle_split(¤t, Some(&c), currently_numeric) { + if skip { + skip = false; + } else { + out.push_back(part); + current.clear(); + currently_numeric = c.is_ascii_digit(); + } + } + current.push(c); + } + + if let Some(part) = handle_split(¤t, None, currently_numeric) { + out.push_back(part); + } + + out +} + +/// Compare two version strings using FlexVer rules. +/// +/// Returns: +/// - `Ordering::Less` if `a` < `b` +/// - `Ordering::Equal` if `a` == `b` +/// - `Ordering::Greater` if `a` > `b` +/// +/// This matches the behavior of flexver-java: +/// - "1.0.0" > "1.0.0-beta" (release > pre-release) +/// - "1.0.0-beta" < "1.0.0+build123" (pre-release < build metadata) +pub fn compare(left: &str, right: &str) -> Ordering { + let iter = VersionComparisonIterator { + left: decompose(left), + right: decompose(right), + }; + + for next in iter { + use SortingType::*; + + let current = match next { + // Left ran out first + (Some(l), None) => { + if let SemverPrerelease(_) = l { + Less + } else { + Greater + } + }, + // Right ran out first + (None, Some(r)) => { + if let SemverPrerelease(_) = r { + Greater + } else { + Less + } + }, + // Both have components + (Some(l), Some(r)) => { + match (l, r) { + (Numerical(l, _), Numerical(r, _)) => l.cmp(&r), + (l, r) => l.into_string().cmp(&r.into_string()), + } + }, + (None, None) => unreachable!(), + }; + + if current != Equal { + return current; + } + } + + Equal +} + +/// Version comparison iterator that yields pairs of components +#[derive(Debug)] +struct VersionComparisonIterator { + left: VecDeque, + right: VecDeque, +} + +impl Iterator for VersionComparisonIterator { + type Item = (Option, Option); + + fn next(&mut self) -> Option { + let item = (self.left.pop_front(), self.right.pop_front()); + if let (None, None) = item { + None + } else { + Some(item) + } + } +} + +/// FlexVer type for use with standard library traits +#[derive(Debug, Copy, Clone)] +pub struct FlexVer<'a>(pub &'a str); + +impl PartialEq for FlexVer<'_> { + fn eq(&self, other: &Self) -> bool { + compare(self.0, other.0) == Equal + } +} + +impl Eq for FlexVer<'_> {} + +impl PartialOrd for FlexVer<'_> { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for FlexVer<'_> { + fn cmp(&self, other: &Self) -> Ordering { + compare(self.0, other.0) + } +} + +#[cfg(test)] +mod tests { + use std::cmp::Ordering; + + use super::*; + + fn cmp(a: &str, b: &str) -> Ordering { + compare(a, b) + } + + #[test] + fn test_basic_release_comparison() { + assert_eq!(cmp("1.0.0", "1.0.0"), Ordering::Equal); + assert_eq!(cmp("1.0.0", "1.0.1"), Ordering::Less); + assert_eq!(cmp("1.0.1", "1.0.0"), Ordering::Greater); + assert_eq!(cmp("1.0.0", "1.1.0"), Ordering::Less); + assert_eq!(cmp("1.1.0", "1.0.0"), Ordering::Greater); + assert_eq!(cmp("2.0.0", "1.9.9"), Ordering::Greater); + } + + #[test] + fn test_prerelease_comparison() { + // Release > pre-release + assert_eq!(cmp("1.0.0", "1.0.0-beta"), Ordering::Greater); + assert_eq!(cmp("1.0.0-beta", "1.0.0"), Ordering::Less); + + // Pre-release with tilde + assert_eq!(cmp("1.0.0~1", "1.0.0~2"), Ordering::Less); + assert_eq!(cmp("1.0.0~2", "1.0.0~1"), Ordering::Greater); + assert_eq!(cmp("1.0.0~1", "1.0.0~1"), Ordering::Equal); + } + + #[test] + fn test_prerelease_with_tilde_vs_alpha() { + // In FlexVer, "~" is not treated as a pre-release marker + // Only "-" followed by text marks a pre-release + // So "1.0.0~1" is compared lexicographically vs "1.0.0-beta" + // Since '~' (ASCII 126) > '-' (ASCII 45), "~1" > "-beta" + assert_eq!(cmp("1.0.0~1", "1.0.0-beta"), Ordering::Greater); + assert_eq!(cmp("1.0.0-beta", "1.0.0~1"), Ordering::Less); + } + + #[test] + fn test_build_metadata() { + // Build metadata with + is stripped for comparison + assert_eq!(cmp("1.0.0+build", "1.0.0"), Ordering::Equal); + assert_eq!(cmp("1.0.0+build", "1.0.0-alpha"), Ordering::Greater); + } + + #[test] + fn test_with_file_extensions() { + // File extensions should be handled by string comparison + assert_eq!(cmp("mod-1.0.0.jar", "mod-1.0.0.jar"), Ordering::Equal); + assert!(cmp("mod-1.0.0.jar", "mod-1.0.1.jar").is_lt()); + assert!(cmp("mod-1.0.1.jar", "mod-1.0.0.jar").is_gt()); + } + + #[test] + fn test_complex_versions() { + // Simple version comparison + assert!(cmp("sodium-1.0.0", "sodium-1.0.1").is_lt()); + assert!(cmp("sodium-1.0.1", "sodium-1.0.0").is_gt()); + + // File extensions are NOT stripped - they're part of the version string + // "sodium-1.0.0.jar" < "sodium-1.0.0~1.jar" because '.' (48) < '~' (126) + assert!(cmp("sodium-1.0.0.jar", "sodium-1.0.0~1.jar").is_lt()); + assert!(cmp("sodium-1.0.0~1.jar", "sodium-1.0.0.jar").is_gt()); + + assert!(cmp("fabric-0.15.0.1", "fabric-0.15.0.2").is_lt()); + } + + #[test] + fn test_min_max() { + assert_eq!(FlexVer("1.0.0").min(FlexVer("1.0.0")), FlexVer("1.0.0")); + assert_eq!(FlexVer("a1.2.6").min(FlexVer("b1.7.3")), FlexVer("a1.2.6")); + assert_eq!(FlexVer("b1.7.3").max(FlexVer("a1.2.6")), FlexVer("b1.7.3")); + } + + #[test] + fn test_commutative() { + // If a > b, then b < a + let pairs = vec![ + ("1.0.0", "1.0.1"), + ("1.0.0-beta", "1.0.0"), + ("1.0.0~1", "1.0.0~2"), + ]; + + for (a, b) in pairs { + let ordering = compare(a, b); + let inverse = match ordering { + Ordering::Less => Ordering::Greater, + Ordering::Greater => Ordering::Less, + Ordering::Equal => Ordering::Equal, + }; + assert_eq!( + compare(b, a), + inverse, + "Commutativity violation: {} vs {}", + a, + b + ); + } + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 86947cb..5d52a4a 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,5 +1,7 @@ +pub mod flexver; pub mod hash; pub mod id; +pub use flexver::FlexVer; pub use hash::verify_hash; pub use id::generate_pakku_id; From 66317d98def258f3d057b5c9b79ed17efeace881 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 18 Apr 2026 21:28:58 +0300 Subject: [PATCH 02/18] model/enums: add flexver variant to UpdateStrategy Signed-off-by: NotAShelf Change-Id: I8c82af278d54ed4730e808087fa19e846a6a6964 --- src/model/enums.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/model/enums.rs b/src/model/enums.rs index e56c5da..cd61d13 100644 --- a/src/model/enums.rs +++ b/src/model/enums.rs @@ -79,6 +79,8 @@ impl std::fmt::Display for ProjectSide { pub enum UpdateStrategy { #[serde(rename = "LATEST")] Latest, + #[serde(rename = "FLEXVER")] + FlexVer, #[serde(rename = "NONE")] None, } @@ -88,6 +90,7 @@ impl FromStr for UpdateStrategy { fn from_str(s: &str) -> Result { match s.to_uppercase().as_str() { "LATEST" => Ok(Self::Latest), + "FLEXVER" => Ok(Self::FlexVer), "NONE" => Ok(Self::None), _ => Err(format!("Invalid update strategy: {s}")), } @@ -98,6 +101,7 @@ impl std::fmt::Display for UpdateStrategy { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Latest => write!(f, "LATEST"), + Self::FlexVer => write!(f, "FLEXVER"), Self::None => write!(f, "NONE"), } } From af3cdbf343fe64b9f58e631433906640e3f29c37 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 18 Apr 2026 21:29:16 +0300 Subject: [PATCH 03/18] fetch: use flexver for file selection Signed-off-by: NotAShelf Change-Id: Ia01283a5665ac9497858821f13a7751d6a6a6964 --- src/fetch.rs | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/fetch.rs b/src/fetch.rs index 5049c24..ecf0f28 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -11,8 +11,8 @@ use tokio::sync::Semaphore; use crate::{ error::{PakkerError, Result}, - model::{Config, LockFile, Project, ProjectFile}, - utils::verify_hash, + model::{Config, LockFile, Project, ProjectFile, UpdateStrategy}, + utils::{FlexVer, verify_hash}, }; /// Maximum number of concurrent downloads @@ -326,18 +326,25 @@ impl Fetcher { ))); } - // Prefer release over beta over alpha - let best = compatible_files - .iter() - .max_by_key(|f| { - let type_priority = match f.release_type { - crate::model::ReleaseType::Release => 3, - crate::model::ReleaseType::Beta => 2, - crate::model::ReleaseType::Alpha => 1, - }; - (type_priority, &f.date_published) - }) - .unwrap(); + // Select best file based on update strategy + let best = if project.update_strategy == UpdateStrategy::FlexVer { + let mut sorted: Vec<_> = compatible_files.iter().collect(); + sorted.sort_by(|a, b| FlexVer(&b.file_name).cmp(&FlexVer(&a.file_name))); + *sorted.first().unwrap() + } else { + // Prefer release over beta over alpha, then by date published + compatible_files + .iter() + .max_by_key(|f| { + let type_priority = match f.release_type { + crate::model::ReleaseType::Release => 3, + crate::model::ReleaseType::Beta => 2, + crate::model::ReleaseType::Alpha => 1, + }; + (type_priority, &f.date_published) + }) + .unwrap() + }; Ok(best) } From 2c4058b54a4e124bc29349ffe536e4e75e90dcc3 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 18 Apr 2026 21:29:24 +0300 Subject: [PATCH 04/18] commands/update: use flexver for version sorting Signed-off-by: NotAShelf Change-Id: I4e1cd3247e74247cbde65391510bd3586a6a6964 --- src/cli/commands/update.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/cli/commands/update.rs b/src/cli/commands/update.rs index 785219b..e532342 100644 --- a/src/cli/commands/update.rs +++ b/src/cli/commands/update.rs @@ -7,6 +7,7 @@ use crate::{ error::{MultiError, PakkerError}, model::{Config, LockFile, UpdateStrategy}, ui_utils::{prompt_select, prompt_typo_suggestion, prompt_yes_no}, + utils::FlexVer, }; pub async fn execute( @@ -141,6 +142,15 @@ pub async fn execute( && !updated_project.files.is_empty() && let Some(old_file) = lockfile.projects[idx].files.first() { + // Sort files by FlexVer if that strategy is set + if old_project.update_strategy == UpdateStrategy::FlexVer { + updated_project.files.sort_by(|a, b| { + // Use FlexVer for comparison - b.cmp(a) gives descending order + // (newest first) + FlexVer(&b.file_name).cmp(&FlexVer(&a.file_name)) + }); + } + // Clone data needed for comparisons to avoid borrow issues let new_file_id = updated_project.files.first().unwrap().id.clone(); let new_file_name = From 530ba8b5818d7cca47da7e05ff6fae27ad7361a4 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 18 Apr 2026 21:29:29 +0300 Subject: [PATCH 05/18] build: drop redundant symlink script Signed-off-by: NotAShelf Change-Id: If819a647c6c3ab15eb553967de9bd7fc6a6a6964 --- build.rs | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 build.rs diff --git a/build.rs b/build.rs deleted file mode 100644 index 1d7498e..0000000 --- a/build.rs +++ /dev/null @@ -1,27 +0,0 @@ -use std::fs; - -fn main() { - println!("cargo:rerun-if-changed=build.rs"); -} - -#[cfg(unix)] -pub fn create_pakku_symlink() { - let exe_path = - std::env::current_exe().expect("Failed to get current exe path"); - let exe_dir = exe_path.parent().expect("Failed to get exe directory"); - let pakker_path = exe_dir.join("pakker"); - let pakku_path = exe_dir.join("pakku"); - - if pakker_path.exists() { - if pakku_path.exists() { - let _ = fs::remove_file(&pakku_path); - } - let _ = std::os::unix::fs::symlink(&pakker_path, &pakku_path); - } -} - -#[cfg(not(unix))] -pub fn create_pakku_symlink() { - // No-op on non-Unix systems - println!("This only works on an Unix system! Skipping Pakku symlink.") -} From a8bf8f9f3fa8c4eb08242e6a66f50c4769a5643a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 18 Apr 2026 21:52:57 +0300 Subject: [PATCH 06/18] utils/flexver: handle i64 overflow gracefully Signed-off-by: NotAShelf Change-Id: I66386d97f92744a5c07c04b072bc1a626a6a6964 --- src/utils/flexver.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/utils/flexver.rs b/src/utils/flexver.rs index 7290776..ffd77bb 100644 --- a/src/utils/flexver.rs +++ b/src/utils/flexver.rs @@ -79,10 +79,12 @@ fn decompose(str_in: &str) -> VecDeque { if numeric { return None; } else { - return Some(Numerical( - current.parse::().unwrap(), - current.to_owned(), - )); + return Some( + current + .parse::() + .map(|n| Numerical(n, current.to_owned())) + .unwrap_or_else(|_| Lexical(current.to_owned())), + ); } } From 5772200da9ac989d87f866ca6e3fa8b029db1f68 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 18 Apr 2026 22:18:27 +0300 Subject: [PATCH 07/18] platform/multiplatform: add multiplatform client with cross-ref Signed-off-by: NotAShelf Change-Id: Ie2cf48136e5a9017265a3b0ef26619356a6a6964 --- src/platform.rs | 18 +++ src/platform/curseforge.rs | 12 ++ src/platform/github.rs | 11 ++ src/platform/modrinth.rs | 22 ++++ src/platform/multiplatform.rs | 205 ++++++++++++++++++++++++++++++++++ src/platform/traits.rs | 8 ++ 6 files changed, 276 insertions(+) create mode 100644 src/platform/multiplatform.rs diff --git a/src/platform.rs b/src/platform.rs index c9e0589..da04627 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -1,6 +1,7 @@ mod curseforge; mod github; mod modrinth; +mod multiplatform; mod traits; use std::sync::Arc; @@ -8,6 +9,7 @@ use std::sync::Arc; pub use curseforge::CurseForgePlatform; pub use github::GitHubPlatform; pub use modrinth::ModrinthPlatform; +pub use multiplatform::MultiplatformPlatform; pub use traits::PlatformClient; use crate::{error::Result, http, rate_limiter::RateLimiter}; @@ -55,6 +57,14 @@ fn create_client( api_key, ))) }, + "multiplatform" => { + let cf = CurseForgePlatform::with_client(get_http_client(), api_key); + let mr = ModrinthPlatform::with_client(get_http_client()); + Ok(Box::new(MultiplatformPlatform::new( + Arc::new(cf), + Arc::new(mr), + ))) + }, _ => { Err(crate::error::PakkerError::ConfigError(format!( "Unknown platform: {platform}" @@ -117,4 +127,12 @@ impl PlatformClient for RateLimitedPlatform { self.rate_limiter.wait_for(&self.platform_name).await; self.platform.lookup_by_hash(hash).await } + + async fn request_project_from_slug( + &self, + slug: &str, + ) -> Result> { + self.rate_limiter.wait_for(&self.platform_name).await; + self.platform.request_project_from_slug(slug).await + } } diff --git a/src/platform/curseforge.rs b/src/platform/curseforge.rs index d36efee..4587fc6 100644 --- a/src/platform/curseforge.rs +++ b/src/platform/curseforge.rs @@ -391,6 +391,18 @@ impl PlatformClient for CurseForgePlatform { Ok(None) } + + async fn request_project_from_slug( + &self, + slug: &str, + ) -> Result> { + // Try to fetch project by slug using search API + match self.search_project_by_slug(slug).await { + Ok(cf_project) => Ok(Some(self.convert_project(cf_project))), + Err(PakkerError::ProjectNotFound(_)) => Ok(None), + Err(e) => Err(e), + } + } } // CurseForge API models diff --git a/src/platform/github.rs b/src/platform/github.rs index 0c7a735..57582ea 100644 --- a/src/platform/github.rs +++ b/src/platform/github.rs @@ -403,6 +403,17 @@ impl PlatformClient for GitHubPlatform { Ok(None) } } + + async fn request_project_from_slug( + &self, + slug: &str, + ) -> Result> { + match self.request_project(slug, &[], &[]).await { + Ok(project) => Ok(Some(project)), + Err(PakkerError::ProjectNotFound(_)) => Ok(None), + Err(e) => Err(e), + } + } } // GitHub API models diff --git a/src/platform/modrinth.rs b/src/platform/modrinth.rs index 69f81a2..5663165 100644 --- a/src/platform/modrinth.rs +++ b/src/platform/modrinth.rs @@ -254,6 +254,28 @@ impl PlatformClient for ModrinthPlatform { let url = format!("{MODRINTH_API_BASE}/version_file/{hash}"); self.lookup_by_hash_url(&url).await } + + async fn request_project_from_slug( + &self, + slug: &str, + ) -> Result> { + let url = format!("{MODRINTH_API_BASE}/project/{slug}"); + 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 mr_project: ModrinthProject = response.json().await?; + Ok(Some(self.convert_project(mr_project))) + } } // Modrinth API models diff --git a/src/platform/multiplatform.rs b/src/platform/multiplatform.rs new file mode 100644 index 0000000..21a76ee --- /dev/null +++ b/src/platform/multiplatform.rs @@ -0,0 +1,205 @@ +use std::sync::Arc; + +use async_trait::async_trait; + +use super::traits::PlatformClient; +use crate::{ + error::{PakkerError, Result}, + model::{Project, ProjectFile}, +}; + +/// Multiplatform platform client that aggregates CurseForge and Modrinth. +/// It attempts to resolve projects on both platforms and cross-references +/// them via slugs when a project exists on only one platform. +pub struct MultiplatformPlatform { + curseforge: Arc, + modrinth: Arc, +} + +impl MultiplatformPlatform { + pub fn new( + curseforge: Arc, + modrinth: Arc, + ) -> Self { + Self { + curseforge, + modrinth, + } + } + + /// Try to fetch a project, returning Ok(None) for "not found" errors. + async fn try_request_project( + &self, + client: &Arc, + identifier: &str, + ) -> Result> { + match client.request_project(identifier, &[], &[]).await { + Ok(project) => Ok(Some(project)), + Err(e) => { + let is_not_found = matches!( + e, + PakkerError::ProjectNotFound(_) | PakkerError::InvalidResponse(_) + ); + if is_not_found { Ok(None) } else { Err(e) } + }, + } + } +} + +#[async_trait] +impl PlatformClient for MultiplatformPlatform { + async fn request_project( + &self, + identifier: &str, + _mc_versions: &[String], + _loaders: &[String], + ) -> Result { + // Try both platforms in parallel + let cf_future = self.try_request_project(&self.curseforge, identifier); + let mr_future = self.try_request_project(&self.modrinth, identifier); + + let (cf_result, mr_result) = tokio::join!(cf_future, mr_future); + + // Handle results - extract Options, propagate first error if both fail + let (cf_project, mr_project) = match (cf_result, mr_result) { + (Ok(Some(cf)), Ok(Some(mr))) => (Some(cf), Some(mr)), + (Ok(None), Ok(None)) => (None, None), + (Ok(None), Ok(Some(mr))) => (None, Some(mr)), + (Ok(Some(cf)), Ok(None)) => (Some(cf), None), + (Err(_e), Ok(None)) | (Ok(None), Err(_e)) => (None, None), + (Err(e), Ok(Some(_))) | (Ok(Some(_)), Err(e)) => { + return Err(e); + }, + (Err(e), Err(_)) => return Err(e), + }; + + // Cross-reference: if project exists on only one platform, fetch from the + // other using the slug + let mut cf_project = cf_project; + let mut mr_project = mr_project; + + let mr_found_and_cf_missing = mr_project.is_some() && cf_project.is_none(); + if mr_found_and_cf_missing + && let Some(ref mr) = mr_project + && let Some(cf_slug) = mr.slug.get("curseforge") + && let Ok(Some(cf)) = + self.curseforge.request_project_from_slug(cf_slug).await + { + cf_project = Some(cf); + } + let cf_found_and_mr_missing = cf_project.is_some() && mr_project.is_none(); + if cf_found_and_mr_missing + && let Some(ref cf) = cf_project + && let Some(mr_slug) = cf.slug.get("modrinth") + && let Ok(Some(mr)) = + self.modrinth.request_project_from_slug(mr_slug).await + { + mr_project = Some(mr); + } + + // Merge projects or return whichever was found + let combined = match (cf_project, mr_project) { + (Some(cf), Some(mr)) => cf.merged(mr)?, + (Some(cf), None) => cf, + (None, Some(mr)) => mr, + (None, None) => { + return Err(PakkerError::ProjectNotFound(identifier.to_string())); + }, + }; + + Ok(combined) + } + + async fn request_project_files( + &self, + project_id: &str, + mc_versions: &[String], + loaders: &[String], + ) -> Result> { + // Multiplatform doesn't directly support files - use + // request_project_with_files + let project = self + .request_project_with_files(project_id, mc_versions, loaders) + .await?; + Ok(project.files) + } + + async fn request_project_with_files( + &self, + identifier: &str, + mc_versions: &[String], + loaders: &[String], + ) -> Result { + // First get the combined project from both platforms + let mut project = self + .request_project(identifier, mc_versions, loaders) + .await?; + + // Now fetch files from both platforms in parallel + let cf_project_id = project.id.get("curseforge").cloned(); + let mr_project_id = project.id.get("modrinth").cloned(); + + let cf_files_future = async { + if let Some(ref id) = cf_project_id { + self + .curseforge + .request_project_files(id, mc_versions, loaders) + .await + } else { + Ok(Vec::new()) + } + }; + + let mr_files_future = async { + if let Some(ref id) = mr_project_id { + self + .modrinth + .request_project_files(id, mc_versions, loaders) + .await + } else { + Ok(Vec::new()) + } + }; + + let (cf_files, mr_files) = tokio::join!(cf_files_future, mr_files_future); + + let mut all_files = cf_files?; + all_files.extend(mr_files?); + + project.files = all_files; + Ok(project) + } + + async fn lookup_by_hash(&self, hash: &str) -> Result> { + // Try both platforms in parallel + let cf_future = self.curseforge.lookup_by_hash(hash); + let mr_future = self.modrinth.lookup_by_hash(hash); + + let (cf_result, mr_result) = tokio::join!(cf_future, mr_future); + + match (cf_result?, mr_result?) { + (Some(cf), Some(mr)) => cf.merged(mr).map(Some), + (Some(project), None) | (None, Some(project)) => Ok(Some(project)), + (None, None) => Ok(None), + } + } + + async fn request_project_from_slug( + &self, + slug: &str, + ) -> Result> { + let cf_future = self.curseforge.request_project_from_slug(slug); + let mr_future = self.modrinth.request_project_from_slug(slug); + + let (cf_result, mr_result) = tokio::join!(cf_future, mr_future); + + match (cf_result, mr_result) { + (Ok(Some(cf)), Ok(Some(mr))) => cf.merged(mr).map(Some), + (Ok(Some(project)), Ok(None)) | (Ok(None), Ok(Some(project))) => { + Ok(Some(project)) + }, + (Ok(None), Ok(None)) => Ok(None), + (Err(e), _) | (_, Err(e)) => Err(e), + } + } +} diff --git a/src/platform/traits.rs b/src/platform/traits.rs index 598a72a..db115c9 100644 --- a/src/platform/traits.rs +++ b/src/platform/traits.rs @@ -29,4 +29,12 @@ pub trait PlatformClient: Send + Sync { ) -> Result; async fn lookup_by_hash(&self, hash: &str) -> Result>; + + /// Request a project using its platform-specific slug. + /// This is used by Multiplatform to cross-reference projects between + /// platforms. + async fn request_project_from_slug( + &self, + slug: &str, + ) -> Result>; } From c0c9d741c10d690c69ce3340c6c3a666c2cac6e4 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 18 Apr 2026 22:18:33 +0300 Subject: [PATCH 08/18] model/project: add Project::merged for pure combining Signed-off-by: NotAShelf Change-Id: Idf955432e57d87352dffa961e145fcb76a6a6964 --- src/model/project.rs | 183 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/src/model/project.rs b/src/model/project.rs index f4da4aa..424dec4 100644 --- a/src/model/project.rs +++ b/src/model/project.rs @@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; use super::enums::{ProjectSide, ProjectType, ReleaseType, UpdateStrategy}; +use crate::error::{PakkerError, Result}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Project { @@ -169,6 +170,80 @@ impl Project { self.aliases.extend(other.aliases); } + /// Merge this project with another, returning a new combined project. + /// Like Pakku's `Project.plus()`, this is a pure operation that doesn't + /// modify either project. + /// + /// # Errors + /// Returns `PakkerError::InvalidProject` if the projects have different types + /// or conflicting pakku_links. + pub fn merged(&self, other: Self) -> Result { + if self.r#type != other.r#type { + return Err(PakkerError::InvalidProject(format!( + "Cannot merge projects of different types: {:?} vs {:?}", + self.r#type, other.r#type + ))); + } + + if !other.pakku_links.is_empty() && self.pakku_links != other.pakku_links { + return Err(PakkerError::InvalidProject( + "Cannot merge projects with conflicting pakku_links".to_string(), + )); + } + + // Prefer non-default side + let side = if self.side != ProjectSide::Both { + self.side + } else { + other.side + }; + + let mut id = self.id.clone(); + for (platform, other_id) in other.id { + id.entry(platform).or_insert(other_id); + } + + let mut slug = self.slug.clone(); + for (platform, other_slug) in other.slug { + slug.entry(platform).or_insert(other_slug); + } + + let mut name = self.name.clone(); + for (platform, other_name) in other.name { + name.entry(platform).or_insert(other_name); + } + + let mut files = self.files.clone(); + for file in other.files { + if !files.iter().any(|f| f.id == file.id) { + files.push(file); + } + } + + let mut aliases = self.aliases.clone(); + aliases.extend(other.aliases); + + Ok(Self { + pakku_id: self.pakku_id.clone(), + pakku_links: self.pakku_links.clone(), + r#type: self.r#type, + side, + slug, + name, + id, + update_strategy: self.update_strategy, + redistributable: self.redistributable && other.redistributable, + subpath: self.subpath.clone().or(other.subpath.clone()), + aliases, + export: if self.export { + self.export + } else { + other.export + }, + files, + }) + } + /// Check if versions match across all providers. /// Returns true if all provider files have the same version/file, /// or if there's only one provider. @@ -760,4 +835,112 @@ mod tests { let url = file.get_site_url(&project); assert!(url.is_none()); } + + #[test] + fn test_merged_different_types_returns_error() { + let mut p1 = + Project::new("id1".to_string(), ProjectType::Mod, ProjectSide::Both); + p1.name.insert("modrinth".to_string(), "Mod1".to_string()); + + let mut p2 = Project::new( + "id2".to_string(), + ProjectType::ResourcePack, + ProjectSide::Both, + ); + p2.name.insert("modrinth".to_string(), "RP1".to_string()); + + assert!(p1.merged(p2).is_err()); + } + + #[test] + fn test_merged_combines_ids_and_slugs() { + let mut p1 = + Project::new("id1".to_string(), ProjectType::Mod, ProjectSide::Both); + p1.add_platform( + "modrinth".to_string(), + "mr1".to_string(), + "mod1".to_string(), + "Mod 1".to_string(), + ); + + let mut p2 = + Project::new("id2".to_string(), ProjectType::Mod, ProjectSide::Both); + p2.add_platform( + "curseforge".to_string(), + "cf1".to_string(), + "mod1".to_string(), + "Mod 1".to_string(), + ); + + let merged = p1.merged(p2).unwrap(); + assert_eq!(merged.id.get("modrinth"), Some(&"mr1".to_string())); + assert_eq!(merged.id.get("curseforge"), Some(&"cf1".to_string())); + assert_eq!(merged.slug.get("modrinth"), Some(&"mod1".to_string())); + assert_eq!(merged.slug.get("curseforge"), Some(&"mod1".to_string())); + } + + #[test] + fn test_merged_prefers_non_both_side() { + let p1 = + Project::new("id1".to_string(), ProjectType::Mod, ProjectSide::Client); + let p2 = + Project::new("id2".to_string(), ProjectType::Mod, ProjectSide::Both); + + let merged = p1.merged(p2.clone()).unwrap(); + assert_eq!(merged.side, ProjectSide::Client); + + let merged2 = p2.merged(p1).unwrap(); + assert_eq!(merged2.side, ProjectSide::Client); + } + + #[test] + fn test_merged_preserves_pakku_id() { + let p1 = + Project::new("id1".to_string(), ProjectType::Mod, ProjectSide::Both); + let p2 = + Project::new("id2".to_string(), ProjectType::Mod, ProjectSide::Both); + + let merged = p1.merged(p2).unwrap(); + assert_eq!(merged.pakku_id, Some("id1".to_string())); + } + + #[test] + fn test_merged_deduplicates_files() { + let mut p1 = + Project::new("id1".to_string(), ProjectType::Mod, ProjectSide::Both); + p1.files.push(ProjectFile { + file_type: "modrinth".to_string(), + file_name: "mod-1.0.0.jar".to_string(), + mc_versions: vec!["1.20.1".to_string()], + loaders: vec!["fabric".to_string()], + release_type: ReleaseType::Release, + url: "https://example.com/mod.jar".to_string(), + id: "file1".to_string(), + parent_id: "mod123".to_string(), + hashes: HashMap::new(), + required_dependencies: vec![], + size: 1024, + date_published: "2024-01-01T00:00:00Z".to_string(), + }); + + let mut p2 = + Project::new("id2".to_string(), ProjectType::Mod, ProjectSide::Both); + p2.files.push(ProjectFile { + file_type: "modrinth".to_string(), + file_name: "mod-1.0.0.jar".to_string(), + mc_versions: vec!["1.20.1".to_string()], + loaders: vec!["fabric".to_string()], + release_type: ReleaseType::Release, + url: "https://example.com/mod.jar".to_string(), + id: "file1".to_string(), + parent_id: "mod123".to_string(), + hashes: HashMap::new(), + required_dependencies: vec![], + size: 1024, + date_published: "2024-01-01T00:00:00Z".to_string(), + }); + + let merged = p1.merged(p2).unwrap(); + assert_eq!(merged.files.len(), 1); + } } From 0048a1cd73261c072079f707502d313ee03f2e79 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 18 Apr 2026 22:18:40 +0300 Subject: [PATCH 09/18] fetch: add retry support for downloads Signed-off-by: NotAShelf Change-Id: I5920652b1f84cd8d03e3f8c9d17e5aa76a6a6964 --- src/cli/commands/fetch.rs | 4 +++- src/fetch.rs | 50 ++++++++++++++++++++++++++++++++------- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/cli/commands/fetch.rs b/src/cli/commands/fetch.rs index 9b88b1d..9658a73 100644 --- a/src/cli/commands/fetch.rs +++ b/src/cli/commands/fetch.rs @@ -38,7 +38,9 @@ pub async fn execute( let _guard = OperationGuard::new(coordinator, operation_id); // Create fetcher with shelve option - let fetcher = Fetcher::new(".").with_shelve(args.shelve); + let fetcher = Fetcher::new(".") + .with_shelve(args.shelve) + .with_retry(args.retry); // Fetch all projects (progress indicators handled in fetch.rs) fetcher.fetch_all(&lockfile, &config).await?; diff --git a/src/fetch.rs b/src/fetch.rs index ecf0f28..7c7d41c 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -19,17 +19,19 @@ use crate::{ const MAX_CONCURRENT_DOWNLOADS: usize = 8; pub struct Fetcher { - client: Client, - base_path: PathBuf, - shelve: bool, + client: Client, + base_path: PathBuf, + shelve: bool, + retry_count: u32, } impl Fetcher { pub fn new>(base_path: P) -> Self { Self { - client: Client::new(), - base_path: base_path.as_ref().to_path_buf(), - shelve: false, + client: Client::new(), + base_path: base_path.as_ref().to_path_buf(), + shelve: false, + retry_count: 0, } } @@ -38,6 +40,11 @@ impl Fetcher { self } + pub const fn with_retry(mut self, retry_count: u32) -> Self { + self.retry_count = retry_count; + self + } + pub async fn sync(&self, lockfile: &LockFile, config: &Config) -> Result<()> { self.fetch_all(lockfile, config).await } @@ -96,6 +103,7 @@ impl Fetcher { client, base_path, shelve: false, // Shelving happens at sync level, not per-project + retry_count: 0, }; let result = fetcher.fetch_project(&project, lockfile, config).await; @@ -389,14 +397,39 @@ impl Fetcher { } } - /// Download a file from URL to target path + /// Download a file from URL to target path with retry async fn download_file(&self, url: &str, target_path: &Path) -> Result<()> { // Create parent directory if let Some(parent) = target_path.parent() { fs::create_dir_all(parent)?; } - // Download file + let max_attempts = self.retry_count.saturating_add(1); + + for attempt in 0..max_attempts { + match self.download_single_attempt(url, target_path).await { + Ok(()) => return Ok(()), + Err(e) if attempt < self.retry_count => { + log::warn!( + "Download attempt {}/{} failed for {}, retrying...", + attempt + 1, + max_attempts, + url + ); + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + }, + Err(e) => return Err(e), + } + } + + Ok(()) + } + + async fn download_single_attempt( + &self, + url: &str, + target_path: &Path, + ) -> Result<()> { let response = self.client.get(url).send().await?; if !response.status().is_success() { @@ -405,7 +438,6 @@ impl Fetcher { let bytes = response.bytes().await?; - // Write to temporary file first (atomic write) let temp_path = target_path.with_extension("tmp"); fs::write(&temp_path, bytes)?; fs::rename(temp_path, target_path)?; From 838ba827907404b2724351495c934803abbe84c2 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 18 Apr 2026 22:58:31 +0300 Subject: [PATCH 10/18] sync: batch file identification via hash lookup Signed-off-by: NotAShelf Change-Id: I85d3f1265cad1996340ac98ac9ee1f7e6a6a6964 --- src/cli/commands/sync.rs | 92 ++++++++++++++++++++++++++++++++++- src/platform.rs | 12 +++++ src/platform/curseforge.rs | 72 +++++++++++++++++++++++++++ src/platform/modrinth.rs | 59 ++++++++++++++++++++++ src/platform/multiplatform.rs | 27 ++++++++++ 5 files changed, 261 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index e806cfc..715268d 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -63,6 +63,8 @@ pub async fn execute( ); if no_filter || args.additions { + let mut file_hashes = Vec::new(); + for (file_path, _) in &additions { spinner .set_message(format!("Processing addition: {}", file_path.display())); @@ -71,7 +73,34 @@ pub async fn execute( false, global_yes, )? { - add_file_to_lockfile(&mut lockfile, file_path, &config).await?; + if let Ok(file_data) = fs::read(file_path) { + use sha1::Digest; + let mut hasher = sha1::Sha1::new(); + hasher.update(&file_data); + let hash = format!("{:x}", hasher.finalize()); + file_hashes.push(FileHash { + path: file_path.clone(), + hash, + }); + } + } + } + + if !file_hashes.is_empty() { + let fallback_hashes = file_hashes.clone(); + let result = add_files_batch(&mut lockfile, file_hashes).await; + if let Err(e) = result { + log::warn!( + "Batch lookup failed, falling back to individual lookups: {}", + e + ); + for fh in fallback_hashes { + if let Err(e) = + add_file_to_lockfile(&mut lockfile, &fh.path, &config).await + { + log::warn!("Failed to add {}: {}", fh.path.display(), e); + } + } } } } @@ -210,3 +239,64 @@ async fn add_file_to_lockfile( println!("⚠ Could not identify {}, skipping", file_path.display()); Ok(()) } + +#[derive(Clone)] +struct FileHash { + path: PathBuf, + hash: String, +} + +async fn add_files_batch( + lockfile: &mut LockFile, + file_hashes: Vec, +) -> Result<()> { + if file_hashes.is_empty() { + return Ok(()); + } + + let modrinth = ModrinthPlatform::new(); + + let hashes: Vec = + file_hashes.iter().map(|fh| fh.hash.clone()).collect(); + + let projects = modrinth + .request_projects_from_hashes(&hashes, "sha1") + .await?; + + let mut matched_indices: std::collections::HashSet = + std::collections::HashSet::new(); + let mut added_pakku_ids: std::collections::HashSet = + std::collections::HashSet::new(); + + for project in &projects { + let pakku_id = match &project.pakku_id { + Some(id) => id.clone(), + None => continue, + }; + if added_pakku_ids.contains(&pakku_id) { + continue; + } + for file_info in &project.files { + for (idx, fh) in file_hashes.iter().enumerate() { + if !matched_indices.contains(&idx) + && file_info.hashes.get("sha1").map(|s| s.as_str()) == Some(&fh.hash) + { + lockfile.add_project(project.clone()); + added_pakku_ids.insert(pakku_id.clone()); + matched_indices.insert(idx); + println!("✓ Added {} (from Modrinth)", fh.path.display()); + break; + } + } + } + } + + for (idx, fh) in file_hashes.iter().enumerate() { + if matched_indices.contains(&idx) { + continue; + } + println!("⚠ Could not identify {}, skipping", fh.path.display()); + } + + Ok(()) +} diff --git a/src/platform.rs b/src/platform.rs index da04627..3c26574 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -135,4 +135,16 @@ impl PlatformClient for RateLimitedPlatform { self.rate_limiter.wait_for(&self.platform_name).await; self.platform.request_project_from_slug(slug).await } + + async fn request_projects_from_hashes( + &self, + hashes: &[String], + algorithm: &str, + ) -> Result> { + self.rate_limiter.wait_for(&self.platform_name).await; + self + .platform + .request_projects_from_hashes(hashes, algorithm) + .await + } } diff --git a/src/platform/curseforge.rs b/src/platform/curseforge.rs index 4587fc6..f1f8008 100644 --- a/src/platform/curseforge.rs +++ b/src/platform/curseforge.rs @@ -403,6 +403,78 @@ impl PlatformClient for CurseForgePlatform { Err(e) => Err(e), } } + + async fn request_projects_from_hashes( + &self, + hashes: &[String], + _algorithm: &str, + ) -> Result> { + if hashes.is_empty() { + return Ok(Vec::new()); + } + + let fingerprints: Vec = hashes + .iter() + .filter_map(|h| h.parse::().ok()) + .collect(); + + if fingerprints.is_empty() { + return Ok(Vec::new()); + } + + #[derive(Serialize)] + struct FingerprintRequest { + fingerprints: Vec, + } + + let url = format!("{CURSEFORGE_API_BASE}/fingerprints/432"); + let response = self + .client + .post(&url) + .headers(self.get_headers()?) + .json(&FingerprintRequest { + fingerprints: fingerprints.clone(), + }) + .send() + .await?; + + if !response.status().is_success() { + return Err(PakkerError::PlatformApiError(format!( + "CurseForge batch API error: {}", + response.status() + ))); + } + + let response_data: serde_json::Value = response.json().await?; + + let matches = response_data["data"]["exactMatches"] + .as_array() + .cloned() + .unwrap_or_default(); + + let mut projects = Vec::new(); + let mut seen_ids = std::collections::HashSet::new(); + + for m in matches { + if let Some(file) = m["file"].as_object() { + if let Some(mod_id) = file["modId"].as_u64() { + let mod_id_str = mod_id.to_string(); + if seen_ids.contains(&mod_id_str) { + continue; + } + seen_ids.insert(mod_id_str.clone()); + + if let Ok(project) = + self.request_project_with_files(&mod_id_str, &[], &[]).await + { + projects.push(project); + } + } + } + } + + Ok(projects) + } } // CurseForge API models diff --git a/src/platform/modrinth.rs b/src/platform/modrinth.rs index 5663165..906a264 100644 --- a/src/platform/modrinth.rs +++ b/src/platform/modrinth.rs @@ -276,6 +276,65 @@ impl PlatformClient for ModrinthPlatform { let mr_project: ModrinthProject = response.json().await?; Ok(Some(self.convert_project(mr_project))) } + + async fn request_projects_from_hashes( + &self, + hashes: &[String], + algorithm: &str, + ) -> Result> { + if hashes.is_empty() { + return Ok(Vec::new()); + } + + #[derive(Serialize)] + struct HashBatchRequest<'a> { + hashes: &'a [String], + algorithm: &'a str, + } + + #[derive(Debug, Deserialize)] + struct HashBatchResponse { + project_id: String, + } + + let url = format!("{MODRINTH_API_BASE}/version_files"); + let response = self + .client + .post(&url) + .json(&HashBatchRequest { hashes, algorithm }) + .send() + .await?; + + if !response.status().is_success() { + return Err(PakkerError::PlatformApiError(format!( + "Modrinth batch API error: {}", + response.status() + ))); + } + + let versions_map: std::collections::HashMap = + response.json().await?; + + let mut projects = Vec::new(); + let mut seen_project_ids = std::collections::HashSet::new(); + + for version in versions_map.values() { + if seen_project_ids.contains(&version.project_id) { + continue; + } + seen_project_ids.insert(version.project_id.clone()); + + match self + .request_project_with_files(&version.project_id, &[], &[]) + .await + { + Ok(project) => projects.push(project), + Err(_) => continue, + } + } + + Ok(projects) + } } // Modrinth API models diff --git a/src/platform/multiplatform.rs b/src/platform/multiplatform.rs index 21a76ee..5ff8a8b 100644 --- a/src/platform/multiplatform.rs +++ b/src/platform/multiplatform.rs @@ -202,4 +202,31 @@ impl PlatformClient for MultiplatformPlatform { (Err(e), _) | (_, Err(e)) => Err(e), } } + + async fn request_projects_from_hashes( + &self, + hashes: &[String], + algorithm: &str, + ) -> Result> { + let cf_future = self + .curseforge + .request_projects_from_hashes(hashes, algorithm); + let mr_future = self + .modrinth + .request_projects_from_hashes(hashes, algorithm); + + let (cf_projects, mr_projects) = tokio::join!(cf_future, mr_future); + + let mut all_projects = cf_projects?; + for mr_project in mr_projects? { + if !all_projects.iter().any(|p| { + p.id.get("modrinth") == mr_project.id.get("modrinth") + || p.id.get("curseforge") == mr_project.id.get("curseforge") + }) { + all_projects.push(mr_project); + } + } + + Ok(all_projects) + } } From e19df15ae55119fca592fc35c532f488eb9f442c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 19 Apr 2026 00:20:46 +0300 Subject: [PATCH 11/18] flexver: fix ASCII value for '.' in comment Signed-off-by: NotAShelf Change-Id: Ib48589583e34742da5ca7d173ac0f0756a6a6964 --- src/utils/flexver.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/flexver.rs b/src/utils/flexver.rs index ffd77bb..078dbf8 100644 --- a/src/utils/flexver.rs +++ b/src/utils/flexver.rs @@ -286,7 +286,7 @@ mod tests { assert!(cmp("sodium-1.0.1", "sodium-1.0.0").is_gt()); // File extensions are NOT stripped - they're part of the version string - // "sodium-1.0.0.jar" < "sodium-1.0.0~1.jar" because '.' (48) < '~' (126) + // "sodium-1.0.0.jar" < "sodium-1.0.0~1.jar" because '.' (46) < '~' (126) assert!(cmp("sodium-1.0.0.jar", "sodium-1.0.0~1.jar").is_lt()); assert!(cmp("sodium-1.0.0~1.jar", "sodium-1.0.0.jar").is_gt()); From 20ea3c680b1aa7381fbf963b670df4d413d5176b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 19 Apr 2026 00:33:20 +0300 Subject: [PATCH 12/18] platform: add rustdoc to various methods Signed-off-by: NotAShelf Change-Id: Ic4d2bd6f3baf97ce30dbf8709331f6f66a6a6964 --- src/platform/curseforge.rs | 2 ++ src/platform/github.rs | 14 ++++++++++++++ src/platform/modrinth.rs | 2 ++ src/platform/multiplatform.rs | 2 ++ src/platform/traits.rs | 13 +++++++++++++ 5 files changed, 33 insertions(+) diff --git a/src/platform/curseforge.rs b/src/platform/curseforge.rs index f1f8008..2cc7b90 100644 --- a/src/platform/curseforge.rs +++ b/src/platform/curseforge.rs @@ -404,6 +404,8 @@ impl PlatformClient for CurseForgePlatform { } } + /// Uses CurseForge's `/fingerprints/432` endpoint to resolve projects by + /// their hashes in batch. async fn request_projects_from_hashes( &self, hashes: &[String], diff --git a/src/platform/github.rs b/src/platform/github.rs index 57582ea..802e3c7 100644 --- a/src/platform/github.rs +++ b/src/platform/github.rs @@ -414,6 +414,20 @@ impl PlatformClient for GitHubPlatform { Err(e) => Err(e), } } + + /// GitHub does not support hash-based batch lookup. Returns an empty list. + async fn request_projects_from_hashes( + &self, + hashes: &[String], + algorithm: &str, + ) -> Result> { + log::debug!( + "GitHub does not support batch hash lookup ({} hashes, algorithm={})", + hashes.len(), + algorithm + ); + Ok(Vec::new()) + } } // GitHub API models diff --git a/src/platform/modrinth.rs b/src/platform/modrinth.rs index 906a264..94ebf84 100644 --- a/src/platform/modrinth.rs +++ b/src/platform/modrinth.rs @@ -277,6 +277,8 @@ impl PlatformClient for ModrinthPlatform { Ok(Some(self.convert_project(mr_project))) } + /// Uses Modrinth's `/v2/version_files` endpoint to resolve projects by + /// their hashes in batch. async fn request_projects_from_hashes( &self, hashes: &[String], diff --git a/src/platform/multiplatform.rs b/src/platform/multiplatform.rs index 5ff8a8b..521f4ec 100644 --- a/src/platform/multiplatform.rs +++ b/src/platform/multiplatform.rs @@ -203,6 +203,8 @@ impl PlatformClient for MultiplatformPlatform { } } + /// Delegates to both CurseForge and Modrinth in parallel, then deduplicates + /// results. async fn request_projects_from_hashes( &self, hashes: &[String], diff --git a/src/platform/traits.rs b/src/platform/traits.rs index db115c9..8319480 100644 --- a/src/platform/traits.rs +++ b/src/platform/traits.rs @@ -37,4 +37,17 @@ pub trait PlatformClient: Send + Sync { &self, slug: &str, ) -> Result>; + + /// Request multiple projects by their hashes (Modrinth) or bytes + /// (CurseForge). + /// + /// # Returns + /// + /// A list of projects found. Platforms that do not support hash-based + /// lookup return an empty list. + async fn request_projects_from_hashes( + &self, + hashes: &[String], + algorithm: &str, + ) -> Result>; } From 020514cd7ac9f88738c9c8e110156d9e575e370a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 19 Apr 2026 00:36:12 +0300 Subject: [PATCH 13/18] nix: bump nixpkgs Signed-off-by: NotAShelf Change-Id: I7909d4e7d665517c5cebcc4f7906d1f76a6a6964 --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index dbf2fd5..dfdfdf9 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1770197578, - "narHash": "sha256-AYqlWrX09+HvGs8zM6ebZ1pwUqjkfpnv8mewYwAo+iM=", + "lastModified": 1776548001, + "narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "00c21e4c93d963c50d4c0c89bfa84ed6e0694df2", + "rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc", "type": "github" }, "original": { From 8b2140c057ad7fc9c226ff8e8372b5aec58070df Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 21 Apr 2026 17:21:39 +0300 Subject: [PATCH 14/18] build: bump all dependencies and set MSRV to 1.94; fix build failures Signed-off-by: NotAShelf Change-Id: I7d331410864358d30191781d1e6c23f46a6a6964 --- Cargo.lock | 265 +++++++++++++++++++--------------- Cargo.toml | 35 +++-- src/cli/commands/sync.rs | 33 +++-- src/fetch.rs | 2 +- src/model/config.rs | 11 +- src/model/fork.rs | 2 +- src/model/project.rs | 14 +- src/platform/curseforge.rs | 30 ++-- src/platform/multiplatform.rs | 4 +- src/platform/traits.rs | 2 +- src/utils/flexver.rs | 19 +-- src/utils/hash.rs | 25 +++- 12 files changed, 247 insertions(+), 195 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a73442d..b3f5d4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,9 +30,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -51,9 +51,9 @@ checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -80,9 +80,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "assert-json-diff" @@ -160,6 +160,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -228,15 +237,15 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] [[package]] name = "clap" -version = "4.5.58" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -244,9 +253,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.58" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -256,9 +265,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -330,6 +339,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -380,21 +395,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - [[package]] name = "crc32fast" version = "1.5.0" @@ -437,6 +437,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "deflate64" version = "0.1.10" @@ -470,11 +479,22 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -528,9 +548,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ "anstream", "anstyle", @@ -607,9 +627,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -622,9 +642,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -632,15 +652,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -649,15 +669,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -666,21 +686,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -690,7 +710,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -814,7 +833,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -862,6 +881,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -1049,9 +1077,9 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.18.3" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" dependencies = [ "console", "portable-atomic", @@ -1099,9 +1127,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.18" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" dependencies = [ "jiff-static", "log", @@ -1112,9 +1140,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.18" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", @@ -1187,9 +1215,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.181" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libgit2-sys" @@ -1242,9 +1270,9 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -1281,22 +1309,21 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "lzma-rust2" -version = "0.15.6" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f7337d278fec032975dc884152491580dd23750ee957047856735fe0e61ede" +checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae" dependencies = [ - "crc", - "sha2", + "sha2 0.10.9", ] [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest", + "digest 0.11.2", ] [[package]] @@ -1323,9 +1350,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -1418,14 +1445,14 @@ dependencies = [ "log", "md-5", "mockito", - "rand 0.10.0", + "rand 0.10.1", "regex", "reqwest", "semver", "serde", "serde_json", - "sha1", - "sha2", + "sha1 0.11.0", + "sha2 0.11.0", "strsim", "tempfile", "textwrap", @@ -1465,7 +1492,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", + "digest 0.10.7", "hmac", ] @@ -1550,9 +1577,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -1615,9 +1642,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.43" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1640,9 +1667,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.1", @@ -1774,9 +1801,9 @@ checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.10.0", "errno", @@ -1921,9 +1948,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -1988,7 +2015,18 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -1999,7 +2037,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -2056,12 +2105,12 @@ checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2084,9 +2133,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -2136,9 +2185,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.25.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.4.1", @@ -2245,9 +2294,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -2262,9 +2311,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -3090,20 +3139,6 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] [[package]] name = "zerotrie" @@ -3140,9 +3175,9 @@ dependencies = [ [[package]] name = "zip" -version = "7.4.0" +version = "8.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980" +checksum = "dcab981e19633ebcf0b001ddd37dd802996098bc1864f90b7c5d970ce76c1d59" dependencies = [ "aes", "bzip2", @@ -3157,7 +3192,7 @@ dependencies = [ "memchr", "pbkdf2", "ppmd-rust", - "sha1", + "sha1 0.10.6", "time", "typed-path", "zeroize", diff --git a/Cargo.toml b/Cargo.toml index 411bb0e..4cb5319 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,45 +3,45 @@ name = "pakker" version = "0.1.0" edition = "2024" authors = [ "NotAShelf " ] -rust-version = "1.91.0" +rust-version = "1.94.0" readme = true [dependencies] -anyhow = "1.0.101" +anyhow = "1.0.102" async-trait = "0.1.89" -clap = { version = "4.5.58", features = [ "derive" ] } +clap = { version = "4.6.1", features = [ "derive" ] } comfy-table = "7.2.2" dialoguer = "0.12.0" -env_logger = "0.11.9" -futures = "0.3.31" +env_logger = "0.11.10" +futures = "0.3.32" git2 = "0.20.4" glob = "0.3.3" -indicatif = "0.18.3" +indicatif = "0.18.4" keyring = "3.6.3" -libc = "0.2.181" +libc = "0.2.185" log = "0.4.29" -md-5 = "0.10.6" -rand = "0.10.0" +md-5 = "0.11.0" +rand = "0.10.1" regex = "1.12.3" reqwest = { version = "0.13.2", features = [ "json" ] } -semver = "1.0.27" +semver = "1.0.28" serde = { version = "1.0.228", features = [ "derive" ] } serde_json = "1.0.149" -sha1 = "0.10.6" -sha2 = "0.10.9" +sha1 = "0.11.0" +sha2 = "0.11.0" strsim = "0.11.1" -tempfile = "3.25.0" +tempfile = "3.27.0" textwrap = "0.16.2" thiserror = "2.0.18" -tokio = { version = "1.49.0", features = [ "full" ] } +tokio = { version = "1.52.1", features = [ "full" ] } walkdir = "2.5.0" yansi = "1.0.1" -zip = "7.4.0" +zip = "8.5.1" [dev-dependencies] mockito = "1.7.2" -tempfile = "3.25.0" +tempfile = "3.27.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 @@ -51,3 +51,6 @@ opt-level = 3 [profile.dev.package.sha1] opt-level = 3 + +[profile.dev.package.md-5] +opt-level = 3 diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index 715268d..3dac152 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -72,17 +72,17 @@ pub async fn execute( &format!("Add {} to lockfile?", file_path.display()), false, global_yes, - )? { - if let Ok(file_data) = fs::read(file_path) { - use sha1::Digest; - let mut hasher = sha1::Sha1::new(); - hasher.update(&file_data); - let hash = format!("{:x}", hasher.finalize()); - file_hashes.push(FileHash { - path: file_path.clone(), - hash, - }); - } + )? && let Ok(file_data) = fs::read(file_path) + { + use sha1::Digest; + let mut hasher = sha1::Sha1::new(); + hasher.update(&file_data); + let hash = + crate::utils::hash::hash_to_hex(hasher.finalize().as_slice()); + file_hashes.push(FileHash { + path: file_path.clone(), + hash, + }); } } @@ -91,8 +91,7 @@ pub async fn execute( let result = add_files_batch(&mut lockfile, file_hashes).await; if let Err(e) = result { log::warn!( - "Batch lookup failed, falling back to individual lookups: {}", - e + "Batch lookup failed, falling back to individual lookups: {e}" ); for fh in fallback_hashes { if let Err(e) = @@ -220,7 +219,7 @@ async fn add_file_to_lockfile( use sha1::Digest; let mut hasher = sha1::Sha1::new(); hasher.update(&file_data); - let hash = format!("{:x}", hasher.finalize()); + let hash = crate::utils::hash::hash_to_hex(hasher.finalize().as_slice()); // Try Modrinth first (SHA-1 hash) if let Ok(Some(project)) = modrinth.lookup_by_hash(&hash).await { @@ -279,7 +278,11 @@ async fn add_files_batch( for file_info in &project.files { for (idx, fh) in file_hashes.iter().enumerate() { if !matched_indices.contains(&idx) - && file_info.hashes.get("sha1").map(|s| s.as_str()) == Some(&fh.hash) + && file_info + .hashes + .get("sha1") + .map(std::string::String::as_str) + == Some(&fh.hash) { lockfile.add_project(project.clone()); added_pakku_ids.insert(pakku_id.clone()); diff --git a/src/fetch.rs b/src/fetch.rs index 7c7d41c..40196d5 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -409,7 +409,7 @@ impl Fetcher { for attempt in 0..max_attempts { match self.download_single_attempt(url, target_path).await { Ok(()) => return Ok(()), - Err(e) if attempt < self.retry_count => { + Err(_e) if attempt < self.retry_count => { log::warn!( "Download attempt {}/{} failed for {}, retrying...", attempt + 1, diff --git a/src/model/config.rs b/src/model/config.rs index c65a588..f5364bf 100644 --- a/src/model/config.rs +++ b/src/model/config.rs @@ -119,18 +119,17 @@ impl Config { Ok(config) }, Ok(ConfigWrapper::Pakku { pakku }) => { - let name = pakku - .parent - .as_ref() - .map(|p| { + let name = pakku.parent.as_ref().map_or_else( + || "unknown".to_string(), + |p| { p.id .split('/') .next_back() .unwrap_or(&p.id) .trim_end_matches(".git") .to_string() - }) - .unwrap_or_else(|| "unknown".to_string()); + }, + ); let version = pakku .parent diff --git a/src/model/fork.rs b/src/model/fork.rs index 716ab55..0bf4f44 100644 --- a/src/model/fork.rs +++ b/src/model/fork.rs @@ -45,7 +45,7 @@ impl ForkIntegrity { pub fn hash_content(content: &str) -> String { let mut hasher = Sha256::new(); hasher.update(content.as_bytes()); - format!("{:x}", hasher.finalize()) + crate::utils::hash::hash_to_hex(hasher.finalize().as_slice()) } /// Reference type for Git operations diff --git a/src/model/project.rs b/src/model/project.rs index 424dec4..b5d8deb 100644 --- a/src/model/project.rs +++ b/src/model/project.rs @@ -96,8 +96,8 @@ impl Project { .name .values() .next() - .map(|s| s.to_owned()) - .or_else(|| self.pakku_id.as_ref().map(|s| s.to_owned())) + .map(std::borrow::ToOwned::to_owned) + .or_else(|| self.pakku_id.as_ref().map(std::borrow::ToOwned::to_owned)) .unwrap_or_else(|| "unknown".to_string()) } @@ -176,7 +176,7 @@ impl Project { /// /// # Errors /// Returns `PakkerError::InvalidProject` if the projects have different types - /// or conflicting pakku_links. + /// or conflicting `pakku_links`. pub fn merged(&self, other: Self) -> Result { if self.r#type != other.r#type { return Err(PakkerError::InvalidProject(format!( @@ -192,10 +192,10 @@ impl Project { } // Prefer non-default side - let side = if self.side != ProjectSide::Both { - self.side - } else { + let side = if self.side == ProjectSide::Both { other.side + } else { + self.side }; let mut id = self.id.clone(); @@ -338,7 +338,7 @@ impl Project { } // Sort by release type (Release < Beta < Alpha) and date (newest first) - let mut sorted_files = compatible_files.to_vec(); + let mut sorted_files = compatible_files.clone(); sorted_files.sort_by(|a, b| { a.release_type .cmp(&b.release_type) diff --git a/src/platform/curseforge.rs b/src/platform/curseforge.rs index 2cc7b90..db88982 100644 --- a/src/platform/curseforge.rs +++ b/src/platform/curseforge.rs @@ -12,10 +12,10 @@ use crate::{ }; const CURSEFORGE_API_BASE: &str = "https://api.curseforge.com/v1"; -/// CurseForge game version type ID for loader versions (e.g., "fabric", +/// `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 +/// `CurseForge` relation type ID for "required dependency" (mod embeds or /// requires another mod) const DEPENDENCY_RELATION_TYPE_REQUIRED: u32 = 3; @@ -404,7 +404,7 @@ impl PlatformClient for CurseForgePlatform { } } - /// Uses CurseForge's `/fingerprints/432` endpoint to resolve projects by + /// Uses `CurseForge`'s `/fingerprints/432` endpoint to resolve projects by /// their hashes in batch. async fn request_projects_from_hashes( &self, @@ -458,19 +458,19 @@ impl PlatformClient for CurseForgePlatform { let mut seen_ids = std::collections::HashSet::new(); for m in matches { - if let Some(file) = m["file"].as_object() { - if let Some(mod_id) = file["modId"].as_u64() { - let mod_id_str = mod_id.to_string(); - if seen_ids.contains(&mod_id_str) { - continue; - } - seen_ids.insert(mod_id_str.clone()); + if let Some(file) = m["file"].as_object() + && let Some(mod_id) = file["modId"].as_u64() + { + let mod_id_str = mod_id.to_string(); + if seen_ids.contains(&mod_id_str) { + continue; + } + seen_ids.insert(mod_id_str.clone()); - if let Ok(project) = - self.request_project_with_files(&mod_id_str, &[], &[]).await - { - projects.push(project); - } + if let Ok(project) = + self.request_project_with_files(&mod_id_str, &[], &[]).await + { + projects.push(project); } } } diff --git a/src/platform/multiplatform.rs b/src/platform/multiplatform.rs index 521f4ec..734c633 100644 --- a/src/platform/multiplatform.rs +++ b/src/platform/multiplatform.rs @@ -8,7 +8,7 @@ use crate::{ model::{Project, ProjectFile}, }; -/// Multiplatform platform client that aggregates CurseForge and Modrinth. +/// Multiplatform platform client that aggregates `CurseForge` and Modrinth. /// It attempts to resolve projects on both platforms and cross-references /// them via slugs when a project exists on only one platform. pub struct MultiplatformPlatform { @@ -203,7 +203,7 @@ impl PlatformClient for MultiplatformPlatform { } } - /// Delegates to both CurseForge and Modrinth in parallel, then deduplicates + /// Delegates to both `CurseForge` and Modrinth in parallel, then deduplicates /// results. async fn request_projects_from_hashes( &self, diff --git a/src/platform/traits.rs b/src/platform/traits.rs index 8319480..1a36803 100644 --- a/src/platform/traits.rs +++ b/src/platform/traits.rs @@ -39,7 +39,7 @@ pub trait PlatformClient: Send + Sync { ) -> Result>; /// Request multiple projects by their hashes (Modrinth) or bytes - /// (CurseForge). + /// (`CurseForge`). /// /// # Returns /// diff --git a/src/utils/flexver.rs b/src/utils/flexver.rs index 078dbf8..71575c2 100644 --- a/src/utils/flexver.rs +++ b/src/utils/flexver.rs @@ -73,19 +73,16 @@ fn decompose(str_in: &str) -> VecDeque { false }; - use SortingType::*; + use SortingType::{Lexical, Numerical, SemverPrerelease}; if currently_numeric { if numeric { return None; - } else { - return Some( - current - .parse::() - .map(|n| Numerical(n, current.to_owned())) - .unwrap_or_else(|_| Lexical(current.to_owned())), - ); } + return Some(current.parse::().map_or_else( + |_| Lexical(current.to_owned()), + |n| Numerical(n, current.to_owned()), + )); } if !(numeric || c == Some(&'-') || c.is_none()) { @@ -124,7 +121,7 @@ fn decompose(str_in: &str) -> VecDeque { out } -/// Compare two version strings using FlexVer rules. +/// Compare two version strings using `FlexVer` rules. /// /// Returns: /// - `Ordering::Less` if `a` < `b` @@ -141,7 +138,7 @@ pub fn compare(left: &str, right: &str) -> Ordering { }; for next in iter { - use SortingType::*; + use SortingType::{Numerical, SemverPrerelease}; let current = match next { // Left ran out first @@ -198,7 +195,7 @@ impl Iterator for VersionComparisonIterator { } } -/// FlexVer type for use with standard library traits +/// `FlexVer` type for use with standard library traits #[derive(Debug, Copy, Clone)] pub struct FlexVer<'a>(pub &'a str); diff --git a/src/utils/hash.rs b/src/utils/hash.rs index d440b0e..c4f8370 100644 --- a/src/utils/hash.rs +++ b/src/utils/hash.rs @@ -10,6 +10,16 @@ use sha2::{Sha256, Sha512}; use crate::error::{PakkerError, Result}; +pub fn hash_to_hex(hash: impl AsRef<[u8]>) -> String { + use std::fmt::Write; + let bytes = hash.as_ref(); + let mut hex = String::with_capacity(bytes.len() * 2); + for byte in bytes { + write!(hex, "{byte:02x}").unwrap(); + } + hex +} + /// Compute SHA1 hash of a file pub fn compute_sha1>(path: P) -> Result { let file = File::open(path)?; @@ -25,7 +35,7 @@ pub fn compute_sha1>(path: P) -> Result { hasher.update(&buffer[..n]); } - Ok(format!("{:x}", hasher.finalize())) + Ok(hash_to_hex(hasher.finalize().as_slice())) } /// Compute SHA256 hash of a file @@ -43,14 +53,14 @@ pub fn compute_sha256>(path: P) -> Result { hasher.update(&buffer[..n]); } - Ok(format!("{:x}", hasher.finalize())) + Ok(hash_to_hex(hasher.finalize().as_slice())) } /// Compute SHA256 hash of byte data pub fn compute_sha256_bytes(data: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(data); - format!("{:x}", hasher.finalize()) + hash_to_hex(hasher.finalize().as_slice()) } /// Compute SHA512 hash of a file @@ -68,7 +78,7 @@ pub fn compute_sha512>(path: P) -> Result { hasher.update(&buffer[..n]); } - Ok(format!("{:x}", hasher.finalize())) + Ok(hash_to_hex(hasher.finalize().as_slice())) } /// Compute MD5 hash of a file @@ -86,7 +96,12 @@ pub fn compute_md5>(path: P) -> Result { hasher.update(&buffer[..n]); } - Ok(format!("{:x}", hasher.finalize())) + let hash = hasher.finalize(); + let mut hex = String::with_capacity(hash.len() * 2); + for byte in hash { + std::fmt::write(&mut hex, format_args!("{byte:02x}")).unwrap(); + } + Ok(hex) } /// Verify a file's hash against expected value From ace9bcac8aabe8d26bf9286f8985705deb062f20 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 21 Apr 2026 17:37:48 +0300 Subject: [PATCH 15/18] various: fix auto-fixable clippy lints Signed-off-by: NotAShelf Change-Id: I523cd8163d3995efa2f1e8475bbf87316a6a6964 --- src/platform/curseforge.rs | 2 +- src/platform/modrinth.rs | 2 +- src/utils/flexver.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/platform/curseforge.rs b/src/platform/curseforge.rs index db88982..fd6797f 100644 --- a/src/platform/curseforge.rs +++ b/src/platform/curseforge.rs @@ -32,7 +32,7 @@ impl CurseForgePlatform { } } - pub fn with_client(client: Arc, api_key: Option) -> Self { + pub const fn with_client(client: Arc, api_key: Option) -> Self { Self { client, api_key } } diff --git a/src/platform/modrinth.rs b/src/platform/modrinth.rs index 94ebf84..87eed5a 100644 --- a/src/platform/modrinth.rs +++ b/src/platform/modrinth.rs @@ -24,7 +24,7 @@ impl ModrinthPlatform { } } - pub fn with_client(client: Arc) -> Self { + pub const fn with_client(client: Arc) -> Self { Self { client } } diff --git a/src/utils/flexver.rs b/src/utils/flexver.rs index 71575c2..1d562d3 100644 --- a/src/utils/flexver.rs +++ b/src/utils/flexver.rs @@ -187,7 +187,7 @@ impl Iterator for VersionComparisonIterator { fn next(&mut self) -> Option { let item = (self.left.pop_front(), self.right.pop_front()); - if let (None, None) = item { + if item == (None, None) { None } else { Some(item) From b93b234fc28c43d9ce05988ca92caf6adfb7928d Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 21 Apr 2026 18:08:16 +0300 Subject: [PATCH 16/18] build: enforce stricter Clippy lint rules; optimize release profile Signed-off-by: NotAShelf Change-Id: I9077be96783370a26902f46f62afa2826a6a6964 --- Cargo.toml | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 4cb5319..7b74d38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,44 @@ zip = "8.5.1" mockito = "1.7.2" tempfile = "3.27.0" +[lints.clippy] +cargo = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } +style = { level = "warn", priority = -1 } + +# The lint groups above enable some less-than-desirable rules, we should manually +# enable those to keep our sanity. +absolute_paths = "allow" +arbitrary_source_item_ordering = "allow" +enum_variant_names = "allow" +implicit_return = "allow" +missing_docs_in_private_items = "allow" +non_ascii_literal = "allow" +pattern_type_mismatch = "allow" +print_stdout = "allow" +question_mark_used = "allow" +similar_names = "allow" +single_call_fn = "allow" +std_instead_of_core = "allow" +struct_excessive_bools = "allow" +too_long_first_doc_paragraph = "allow" +too_many_lines = "allow" +unused_trait_names = "allow" + +# In the honor of a recent Cloudflare regression +# Let's get rid of them, what the hell +panic = "deny" +unwrap_used = "deny" + +# Less dangerous, but we'd like to know +expect_used = "warn" +todo = "warn" +unimplemented = "warn" +unreachable = "warn" + # Optimize crypto stuff. Building them with optimizations makes that build script # run ~5x faster, more than offsetting the additional build time added to the # libraries themselves. @@ -54,3 +92,8 @@ opt-level = 3 [profile.dev.package.md-5] opt-level = 3 + +[profile.release] +lto = true +opt-level = 3 +strip = true From 61ced09d253b26f16d875f1b3f95dcc91c8b092a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 21 Apr 2026 18:08:41 +0300 Subject: [PATCH 17/18] treewide: fix clippy lints Signed-off-by: NotAShelf Change-Id: I411be69ff31f9cb39cd4cdebc8985b366a6a6964 --- src/cli.rs | 2 +- src/cli/commands/add.rs | 16 ++-- src/cli/commands/add_prj.rs | 18 +++-- src/cli/commands/cfg.rs | 35 ++++---- src/cli/commands/cfg_prj.rs | 37 +++++---- src/cli/commands/credentials.rs | 2 +- src/cli/commands/credentials_set.rs | 7 +- src/cli/commands/diff.rs | 93 ++++++++++++---------- src/cli/commands/export.rs | 19 ++--- src/cli/commands/fetch.rs | 4 +- src/cli/commands/fork.rs | 20 ++--- src/cli/commands/import.rs | 44 +++++----- src/cli/commands/init.rs | 12 ++- src/cli/commands/inspect.rs | 48 +++++------ src/cli/commands/link.rs | 4 +- src/cli/commands/ls.rs | 4 +- src/cli/commands/remote.rs | 12 +-- src/cli/commands/remote_update.rs | 6 +- src/cli/commands/rm.rs | 6 +- src/cli/commands/set.rs | 12 +-- src/cli/commands/status.rs | 65 +++++++-------- src/cli/commands/sync.rs | 38 +++++---- src/cli/commands/unlink.rs | 4 +- src/cli/commands/update.rs | 28 +++++-- src/error.rs | 5 +- src/export.rs | 20 +++-- src/export/profile_config.rs | 4 +- src/export/rules.rs | 119 ++++++++++++++-------------- src/fetch.rs | 52 +++++++----- src/git/mod.rs | 11 ++- src/http.rs | 5 ++ src/ipc.rs | 22 ++--- src/main.rs | 39 +++++---- src/model/credentials.rs | 31 ++++---- src/model/project.rs | 14 +++- src/platform.rs | 2 +- src/platform/curseforge.rs | 33 ++++---- src/platform/github.rs | 34 +++++--- src/platform/modrinth.rs | 33 ++++---- src/resolver.rs | 6 +- src/ui_utils.rs | 4 +- src/utils/flexver.rs | 46 +++++------ src/utils/hash.rs | 4 +- 43 files changed, 557 insertions(+), 463 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 8677bdf..0f93c6d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -84,7 +84,7 @@ pub enum Commands { Credentials(CredentialsArgs), /// Configure modpack properties - Cfg(CfgArgs), + Cfg(Box), /// Manage fork configuration Fork(ForkArgs), diff --git a/src/cli/commands/add.rs b/src/cli/commands/add.rs index 9eb3780..bc1d79c 100644 --- a/src/cli/commands/add.rs +++ b/src/cli/commands/add.rs @@ -12,7 +12,7 @@ fn get_loaders(lockfile: &LockFile) -> Vec { } pub fn create_all_platforms() --> Result>> { +-> HashMap> { const MODRINTH: &str = "modrinth"; const CURSEFORGE: &str = "curseforge"; @@ -27,7 +27,7 @@ pub fn create_all_platforms() platforms.insert(CURSEFORGE.to_owned(), platform); } - Ok(platforms) + platforms } async fn resolve_input( @@ -55,6 +55,10 @@ use std::path::Path; use crate::{cli::AddArgs, model::fork::LocalConfig}; +#[expect( + clippy::future_not_send, + reason = "not required to be Send; only called from single-threaded context" +)] pub async fn execute( args: AddArgs, global_yes: bool, @@ -66,8 +70,8 @@ pub async fn execute( // Load lockfile // 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(".")); + let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new(".")); + let config_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); // Check if lockfile exists (try both pakker-lock.json and pakku-lock.json) let lockfile_exists = @@ -110,7 +114,7 @@ pub async fn execute( let parent_lockfile = parent_paths .iter() .find(|path| path.exists()) - .and_then(|path| LockFile::load(path.parent().unwrap()).ok()) + .and_then(|path| LockFile::load(path.parent()?).ok()) .ok_or_else(|| { PakkerError::IoError(std::io::Error::new( std::io::ErrorKind::NotFound, @@ -141,7 +145,7 @@ pub async fn execute( let _config = Config::load(config_dir).ok(); // Create platforms - let platforms = create_all_platforms()?; + let platforms = create_all_platforms(); let mut new_projects = Vec::new(); let mut errors = MultiError::new(); diff --git a/src/cli/commands/add_prj.rs b/src/cli/commands/add_prj.rs index fd3166c..0135391 100644 --- a/src/cli/commands/add_prj.rs +++ b/src/cli/commands/add_prj.rs @@ -44,6 +44,14 @@ fn get_loaders(lockfile: &LockFile) -> Vec { lockfile.loaders.keys().cloned().collect() } +#[expect( + clippy::future_not_send, + reason = "not required to be Send; only called from single-threaded context" +)] +#[expect( + clippy::too_many_arguments, + reason = "CLI command handler maps directly from clap args" +)] pub async fn execute( cf_arg: Option, mr_arg: Option, @@ -71,8 +79,8 @@ pub async fn execute( log::info!("Adding project with explicit platform specification"); // Load lockfile - let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new(".")); - let config_dir = config_path.parent().unwrap_or(Path::new(".")); + let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new(".")); + let config_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); let mut lockfile = LockFile::load(lockfile_dir)?; @@ -258,7 +266,7 @@ pub async fn execute( if !no_deps { log::info!("Resolving dependencies..."); - let platforms = create_all_platforms()?; + let platforms = create_all_platforms(); let mut resolver = DependencyResolver::new(); let deps = resolver @@ -304,7 +312,7 @@ pub async fn execute( } fn create_all_platforms() --> Result>> { +-> HashMap> { let mut platforms = HashMap::new(); if let Ok(platform) = create_platform("modrinth", None) { @@ -321,7 +329,7 @@ fn create_all_platforms() platforms.insert("github".to_string(), platform); } - Ok(platforms) + platforms } #[cfg(test)] diff --git a/src/cli/commands/cfg.rs b/src/cli/commands/cfg.rs index 6d42b0d..dae35c3 100644 --- a/src/cli/commands/cfg.rs +++ b/src/cli/commands/cfg.rs @@ -8,6 +8,10 @@ use crate::{ ui_utils::prompt_input_optional, }; +#[expect( + clippy::too_many_arguments, + reason = "CLI command handler maps directly from clap args" +)] pub fn execute( config_path: &Path, name: Option, @@ -20,21 +24,27 @@ pub fn execute( worlds_path: Option, shaders_path: Option, ) -> Result<()> { - let config_dir = config_path.parent().unwrap_or(Path::new(".")); + let config_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); let mut config = Config::load(config_dir)?; - let mut changed = false; + let mut changed = name.is_some() + || version.is_some() + || description.is_some() + || author.is_some() + || mods_path.is_some() + || resource_packs_path.is_some() + || data_packs_path.is_some() + || worlds_path.is_some() + || shaders_path.is_some(); // Modpack properties if let Some(new_name) = name { - config.name = new_name.clone(); + config.name.clone_from(&new_name); println!("{}", format!("✓ 'name' set to '{new_name}'").green()); - changed = true; } if let Some(new_version) = version { - config.version = new_version.clone(); + config.version.clone_from(&new_version); println!("{}", format!("✓ 'version' set to '{new_version}'").green()); - changed = true; } if let Some(new_description) = description { @@ -43,20 +53,17 @@ pub fn execute( "{}", format!("✓ 'description' set to '{new_description}'").green() ); - changed = true; } if let Some(new_author) = author { config.author = Some(new_author.clone()); println!("{}", format!("✓ 'author' set to '{new_author}'").green()); - changed = true; } // Project type paths if let Some(path) = mods_path { config.paths.insert("mod".to_string(), path.clone()); println!("{}", format!("✓ 'paths.mod' set to '{path}'").green()); - changed = true; } if let Some(path) = resource_packs_path { @@ -67,25 +74,21 @@ pub fn execute( "{}", format!("✓ 'paths.resource-pack' set to '{path}'").green() ); - changed = true; } if let Some(path) = data_packs_path { config.paths.insert("data-pack".to_string(), path.clone()); println!("{}", format!("✓ 'paths.data-pack' set to '{path}'").green()); - changed = true; } if let Some(path) = worlds_path { config.paths.insert("world".to_string(), path.clone()); println!("{}", format!("✓ 'paths.world' set to '{path}'").green()); - changed = true; } if let Some(path) = shaders_path { config.paths.insert("shader".to_string(), path.clone()); println!("{}", format!("✓ 'paths.shader' set to '{path}'").green()); - changed = true; } if !changed { @@ -99,13 +102,13 @@ pub fn execute( // Prompt for each configurable field if let Ok(Some(new_name)) = prompt_input_optional(" Name") { - config.name = new_name.clone(); + config.name.clone_from(&new_name); println!("{}", format!(" ✓ 'name' set to '{new_name}'").green()); changed = true; } if let Ok(Some(new_version)) = prompt_input_optional(" Version") { - config.version = new_version.clone(); + config.version.clone_from(&new_version); println!( "{}", format!(" ✓ 'version' set to '{new_version}'").green() @@ -136,7 +139,7 @@ pub fn execute( } // Config::save expects directory path, not file path - let config_dir = config_path.parent().unwrap_or(Path::new(".")); + let config_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); config.save(config_dir)?; println!("\n{}", "Configuration updated successfully".green().bold()); diff --git a/src/cli/commands/cfg_prj.rs b/src/cli/commands/cfg_prj.rs index 0c9018f..673c2cd 100644 --- a/src/cli/commands/cfg_prj.rs +++ b/src/cli/commands/cfg_prj.rs @@ -11,10 +11,14 @@ use crate::{ }, }; +#[expect( + clippy::too_many_arguments, + reason = "CLI command handler maps directly from clap args" +)] pub fn execute( config_path: &Path, lockfile_path: &Path, - project: String, + project: &str, r#type: Option<&str>, side: Option<&str>, update_strategy: Option<&str>, @@ -24,30 +28,30 @@ pub fn execute( remove_alias: Option, export: Option, ) -> Result<()> { - let config_dir = config_path.parent().unwrap_or(Path::new(".")); + let config_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); let mut config = Config::load(config_dir)?; - let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new(".")); + let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new(".")); let lockfile = LockFile::load(lockfile_dir)?; // 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 - .get_project(&project) + .get_project(project) .or_else(|| { // Try to find by slug on any platform lockfile .projects .iter() - .find(|p| p.slug.values().any(|s| s.eq_ignore_ascii_case(&project))) + .find(|p| p.slug.values().any(|s| s.eq_ignore_ascii_case(project))) }) .or_else(|| { // Try to find by name on any platform lockfile .projects .iter() - .find(|p| p.name.values().any(|n| n.eq_ignore_ascii_case(&project))) + .find(|p| p.name.values().any(|n| n.eq_ignore_ascii_case(project))) }) - .ok_or_else(|| PakkerError::ProjectNotFound(project.clone()))?; + .ok_or_else(|| PakkerError::ProjectNotFound(project.to_string()))?; let pakku_id = found_project.pakku_id.as_ref().ok_or_else(|| { PakkerError::InvalidProject("Project has no pakku_id".to_string()) @@ -59,7 +63,14 @@ pub fn execute( .cloned() .unwrap_or_default(); - let mut changed = false; + let changed = r#type.is_some() + || side.is_some() + || update_strategy.is_some() + || redistributable.is_some() + || subpath.is_some() + || add_alias.is_some() + || remove_alias.is_some() + || export.is_some(); if let Some(type_str) = r#type { let parsed_type = match type_str.to_uppercase().as_str() { @@ -79,7 +90,6 @@ pub fn execute( "{}", format!("✓ 'type' set to '{parsed_type:?}' for '{pakku_id}'").green() ); - changed = true; } if let Some(side_str) = side { @@ -98,7 +108,6 @@ pub fn execute( "{}", format!("✓ 'side' set to '{parsed_side:?}' for '{pakku_id}'").green() ); - changed = true; } if let Some(strategy_str) = update_strategy { @@ -119,7 +128,6 @@ pub fn execute( ) .green() ); - changed = true; } if let Some(new_redistributable) = redistributable { @@ -131,7 +139,6 @@ pub fn execute( ) .green() ); - changed = true; } if let Some(new_subpath) = subpath { @@ -140,7 +147,6 @@ pub fn execute( "{}", format!("✓ 'subpath' set to '{new_subpath}' for '{pakku_id}'").green() ); - changed = true; } if let Some(alias_to_add) = add_alias { @@ -152,7 +158,6 @@ pub fn execute( "{}", format!("✓ Added alias '{alias_to_add}' for '{pakku_id}'").green() ); - changed = true; } } @@ -165,7 +170,6 @@ pub fn execute( "{}", format!("✓ Removed alias '{alias_to_remove}' from '{pakku_id}'").green() ); - changed = true; } if let Some(new_export) = export { @@ -174,7 +178,6 @@ pub fn execute( "{}", format!("✓ 'export' set to '{new_export}' for '{pakku_id}'").green() ); - changed = true; } if !changed { @@ -187,7 +190,7 @@ pub fn execute( config.set_project_config(pakku_id.clone(), project_config); // Config::save expects directory path, not file path - let config_dir = config_path.parent().unwrap_or(Path::new(".")); + let config_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); config.save(config_dir)?; println!( diff --git a/src/cli/commands/credentials.rs b/src/cli/commands/credentials.rs index 7540668..6f5e51e 100644 --- a/src/cli/commands/credentials.rs +++ b/src/cli/commands/credentials.rs @@ -33,7 +33,7 @@ pub fn execute( return Ok(()); } - let creds = ResolvedCredentials::load()?; + let creds = ResolvedCredentials::load(); let has_any = creds.curseforge_api_key().is_some() || creds.modrinth_token().is_some() diff --git a/src/cli/commands/credentials_set.rs b/src/cli/commands/credentials_set.rs index 60672b4..8cd240e 100644 --- a/src/cli/commands/credentials_set.rs +++ b/src/cli/commands/credentials_set.rs @@ -9,7 +9,9 @@ pub fn execute( github_access_token: Option, ) -> Result<()> { let mut creds = PakkerCredentialsFile::load()?; - let mut updated_any = false; + let updated_any = curseforge_api_key.is_some() + || modrinth_token.is_some() + || github_access_token.is_some(); if let Some(key) = curseforge_api_key { let key = key.trim().to_string(); @@ -22,7 +24,6 @@ pub fn execute( println!("Setting CurseForge API key..."); set_keyring_secret("curseforge_api_key", &key)?; creds.curseforge_api_key = Some(key); - updated_any = true; } if let Some(token) = modrinth_token { @@ -36,7 +37,6 @@ pub fn execute( println!("Setting Modrinth token..."); set_keyring_secret("modrinth_token", &token)?; creds.modrinth_token = Some(token); - updated_any = true; } if let Some(token) = github_access_token { @@ -50,7 +50,6 @@ pub fn execute( println!("Setting GitHub access token..."); set_keyring_secret("github_access_token", &token)?; creds.github_access_token = Some(token); - updated_any = true; } if !updated_any { diff --git a/src/cli/commands/diff.rs b/src/cli/commands/diff.rs index b2b71a6..23f34d5 100644 --- a/src/cli/commands/diff.rs +++ b/src/cli/commands/diff.rs @@ -1,5 +1,6 @@ use std::{ collections::{HashMap, HashSet}, + fmt::Write, fs, path::Path, }; @@ -21,20 +22,20 @@ struct ProjectChange { new_file: Option, } -pub fn execute(args: DiffArgs, _lockfile_path: &Path) -> Result<()> { +pub fn execute(args: &DiffArgs, _lockfile_path: &Path) -> Result<()> { log::info!("Comparing lockfiles"); // Load old lockfile let old_path = Path::new(&args.old_lockfile); - let old_dir = old_path.parent().unwrap_or(Path::new(".")); + let old_dir = old_path.parent().unwrap_or_else(|| Path::new(".")); let old_lockfile = LockFile::load(old_dir)?; // Load current lockfile let current_path = args .current_lockfile .as_ref() - .map_or(Path::new("pakku-lock.json"), Path::new); - let current_dir = current_path.parent().unwrap_or(Path::new(".")); + .map_or_else(|| Path::new("pakku-lock.json"), Path::new); + let current_dir = current_path.parent().unwrap_or_else(|| Path::new(".")); let current_lockfile = LockFile::load(current_dir)?; // Compare metadata @@ -145,6 +146,10 @@ pub fn execute(args: DiffArgs, _lockfile_path: &Path) -> Result<()> { Ok(()) } +#[expect( + clippy::too_many_arguments, + reason = "diff formatting requires all display parameters" +)] fn print_terminal_diff( old: &LockFile, new: &LockFile, @@ -243,6 +248,10 @@ fn print_terminal_diff( } } +#[expect( + clippy::too_many_arguments, + reason = "diff markdown writer requires all context parameters" +)] fn write_markdown_diff( path: &str, old: &LockFile, @@ -260,17 +269,17 @@ fn write_markdown_diff( // Metadata changes if old.target != new.target { - content.push_str(&format!("- Target: {:?}\n", old.target)); - content.push_str(&format!("+ Target: {:?}\n", new.target)); + let _ = writeln!(content, "- Target: {:?}", old.target); + let _ = writeln!(content, "+ Target: {:?}", new.target); } if !mc_removed.is_empty() || !mc_added.is_empty() { content.push_str("\nMinecraft Versions:\n"); for v in mc_removed { - content.push_str(&format!("- {v}\n")); + let _ = writeln!(content, "- {v}"); } for v in mc_added { - content.push_str(&format!("+ {v}\n")); + let _ = writeln!(content, "+ {v}"); } } @@ -278,16 +287,16 @@ fn write_markdown_diff( for (name, old_ver) in old_loaders { if let Some(new_ver) = new_loaders.get(name) { if old_ver != new_ver { - content.push_str(&format!("- {name}: {old_ver}\n")); - content.push_str(&format!("+ {name}: {new_ver}\n")); + let _ = writeln!(content, "- {name}: {old_ver}"); + let _ = writeln!(content, "+ {name}: {new_ver}"); } } else { - content.push_str(&format!("- {name}: {old_ver}\n")); + let _ = writeln!(content, "- {name}: {old_ver}"); } } for (name, new_ver) in new_loaders { if !old_loaders.contains_key(name) { - content.push_str(&format!("+ {name}: {new_ver}\n")); + let _ = writeln!(content, "+ {name}: {new_ver}"); } } @@ -297,16 +306,16 @@ fn write_markdown_diff( for change in changes { match change.change_type { ChangeType::Added => { - content.push_str(&format!("+ {}", change.name)); + let _ = write!(content, "+ {}", change.name); if verbose && let Some(file) = &change.new_file { - content.push_str(&format!(" ({file})")); + let _ = write!(content, " ({file})"); } content.push('\n'); }, ChangeType::Removed => { - content.push_str(&format!("- {}", change.name)); + let _ = write!(content, "- {}", change.name); if verbose && let Some(file) = &change.old_file { - content.push_str(&format!(" ({file})")); + let _ = write!(content, " ({file})"); } content.push('\n'); }, @@ -314,11 +323,11 @@ fn write_markdown_diff( if verbose { if let (Some(old), Some(new)) = (&change.old_file, &change.new_file) { - content.push_str(&format!("- {} ({})\n", change.name, old)); - content.push_str(&format!("+ {} ({})\n", change.name, new)); + let _ = writeln!(content, "- {} ({})", change.name, old); + let _ = writeln!(content, "+ {} ({})", change.name, new); } } else { - content.push_str(&format!("~ {}\n", change.name)); + let _ = writeln!(content, "~ {}", change.name); } }, } @@ -331,6 +340,10 @@ fn write_markdown_diff( Ok(()) } +#[expect( + clippy::too_many_arguments, + reason = "diff markdown writer requires all context parameters" +)] fn write_markdown( path: &str, old: &LockFile, @@ -346,24 +359,25 @@ fn write_markdown( let header = "#".repeat(header_size.min(5)); let mut content = String::new(); - content.push_str(&format!("{header} Lockfile Comparison\n\n")); + let _ = write!(content, "{header} Lockfile Comparison\n\n"); // Target if old.target != new.target { - content.push_str(&format!( + let _ = write!( + content, "**Target:** {:?} → {:?}\n\n", old.target, new.target - )); + ); } // MC versions if !mc_removed.is_empty() || !mc_added.is_empty() { - content.push_str(&format!("{header} Minecraft Versions\n\n")); + let _ = write!(content, "{header} Minecraft Versions\n\n"); for v in mc_removed { - content.push_str(&format!("- ~~{v}~~\n")); + let _ = writeln!(content, "- ~~{v}~~"); } for v in mc_added { - content.push_str(&format!("- **{v}** (new)\n")); + let _ = writeln!(content, "- **{v}** (new)"); } content.push('\n'); } @@ -375,29 +389,28 @@ fn write_markdown( if let Some(new_ver) = new_loaders.get(name) { if old_ver != new_ver { has_loader_changes = true; - loader_content - .push_str(&format!("- **{name}:** {old_ver} → {new_ver}\n")); + let _ = writeln!(loader_content, "- **{name}:** {old_ver} → {new_ver}"); } } else { has_loader_changes = true; - loader_content.push_str(&format!("- ~~{name}: {old_ver}~~\n")); + let _ = writeln!(loader_content, "- ~~{name}: {old_ver}~~"); } } for (name, new_ver) in new_loaders { if !old_loaders.contains_key(name) { has_loader_changes = true; - loader_content.push_str(&format!("- **{name}: {new_ver}** (new)\n")); + let _ = writeln!(loader_content, "- **{name}: {new_ver}** (new)"); } } if has_loader_changes { - content.push_str(&format!("{header} Loaders\n\n")); + let _ = write!(content, "{header} Loaders\n\n"); content.push_str(&loader_content); content.push('\n'); } // Projects if !changes.is_empty() { - content.push_str(&format!("{header} Projects\n\n")); + let _ = write!(content, "{header} Projects\n\n"); let added: Vec<_> = changes .iter() @@ -413,11 +426,11 @@ fn write_markdown( .collect(); if !added.is_empty() { - content.push_str(&format!("{}# Added ({})\n\n", header, added.len())); + let _ = write!(content, "{}# Added ({})\n\n", header, added.len()); for change in added { - content.push_str(&format!("- **{}**", change.name)); + let _ = write!(content, "- **{}**", change.name); if verbose && let Some(file) = &change.new_file { - content.push_str(&format!(" ({file})")); + let _ = write!(content, " ({file})"); } content.push('\n'); } @@ -425,11 +438,11 @@ fn write_markdown( } if !removed.is_empty() { - content.push_str(&format!("{}# Removed ({})\n\n", header, removed.len())); + let _ = write!(content, "{}# Removed ({})\n\n", header, removed.len()); for change in removed { - content.push_str(&format!("- ~~{}~~", change.name)); + let _ = write!(content, "- ~~{}~~", change.name); if verbose && let Some(file) = &change.old_file { - content.push_str(&format!(" ({file})")); + let _ = write!(content, " ({file})"); } content.push('\n'); } @@ -437,13 +450,13 @@ fn write_markdown( } if !updated.is_empty() { - content.push_str(&format!("{}# Updated ({})\n\n", header, updated.len())); + let _ = write!(content, "{}# Updated ({})\n\n", header, updated.len()); for change in updated { - content.push_str(&format!("- **{}**", change.name)); + let _ = write!(content, "- **{}**", change.name); if verbose && let (Some(old), Some(new)) = (&change.old_file, &change.new_file) { - content.push_str(&format!(" ({old} → {new})")); + let _ = write!(content, " ({old} → {new})"); } content.push('\n'); } diff --git a/src/cli/commands/export.rs b/src/cli/commands/export.rs index 6861553..6c27cf5 100644 --- a/src/cli/commands/export.rs +++ b/src/cli/commands/export.rs @@ -9,6 +9,7 @@ use crate::{ utils::hash::compute_sha256_bytes, }; +#[expect(clippy::future_not_send, reason = "not required to be Send")] pub async fn execute( args: ExportArgs, lockfile_path: &Path, @@ -31,8 +32,8 @@ pub async fn execute( log::info!("IO errors will be shown during export"); } - let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new(".")); - let config_dir = config_path.parent().unwrap_or(Path::new(".")); + let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new(".")); + let config_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); // IPC coordination - prevent concurrent operations on the same modpack let ipc = IpcCoordinator::new(config_dir)?; @@ -113,7 +114,7 @@ pub async fn execute( LockFile::load_with_validation(lockfile_dir, false)?; // Merge: start with parent, override with local - merge_lockfiles(parent_lockfile, local_lockfile, local_cfg)? + merge_lockfiles(parent_lockfile, &local_lockfile, local_cfg) } else { log::info!("No local lockfile - using parent lockfile"); parent_lockfile @@ -188,7 +189,7 @@ pub async fn execute( }; // Create exporter - let mut exporter = Exporter::new("."); + let exporter = Exporter::new("."); // Export based on profile argument if let Some(profile_name) = args.profile { @@ -197,7 +198,7 @@ pub async fn execute( .export(&profile_name, &lockfile, &config, Path::new(output_path)) .await?; - println!("Export complete: {output_file:?}"); + println!("Export complete: {}", output_file.display()); } else { // Multi-profile export (Pakker-compatible default behavior) let output_files = exporter @@ -206,7 +207,7 @@ pub async fn execute( println!("\nExported {} files:", output_files.len()); for output_file in output_files { - println!(" - {output_file:?}"); + println!(" - {}", output_file.display()); } } @@ -218,9 +219,9 @@ pub async fn execute( /// with same slug fn merge_lockfiles( parent: LockFile, - local: LockFile, + local: &LockFile, local_config: &LocalConfig, -) -> Result { +) -> LockFile { let mut merged = LockFile { target: parent.target, // Use parent target mc_versions: parent.mc_versions, // Use parent MC versions @@ -298,5 +299,5 @@ fn merge_lockfiles( merged.projects.len() ); - Ok(merged) + merged } diff --git a/src/cli/commands/fetch.rs b/src/cli/commands/fetch.rs index 9658a73..56f1e3f 100644 --- a/src/cli/commands/fetch.rs +++ b/src/cli/commands/fetch.rs @@ -14,8 +14,8 @@ pub async fn execute( config_path: &Path, ) -> Result<()> { // 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(".")); + let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new(".")); + let config_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); let lockfile = LockFile::load(lockfile_dir)?; let config = Config::load(config_dir)?; diff --git a/src/cli/commands/fork.rs b/src/cli/commands/fork.rs index 514257a..8b69a42 100644 --- a/src/cli/commands/fork.rs +++ b/src/cli/commands/fork.rs @@ -49,7 +49,7 @@ pub fn execute(args: &ForkArgs) -> Result<(), PakkerError> { crate::cli::ForkSubcommand::Unset => execute_unset(), crate::cli::ForkSubcommand::Sync => execute_sync(), crate::cli::ForkSubcommand::Promote { projects } => { - execute_promote(projects.clone()) + execute_promote(projects) }, } } @@ -361,13 +361,11 @@ fn execute_set( let config_dir = Path::new("."); let mut local_config = LocalConfig::load(config_dir)?; - if local_config.parent.is_none() { + let Some(mut parent) = local_config.parent else { return Err(PakkerError::Fork( "No parent configured. Run 'pakku fork init' first.".to_string(), )); - } - - let mut parent = local_config.parent.unwrap(); + }; if let Some(url) = git_url { validate_git_url(&url)?; @@ -461,10 +459,12 @@ fn execute_unset() -> Result<(), PakkerError> { // Prompt for confirmation print!("Are you sure you want to remove fork configuration? [y/N] "); - std::io::stdout().flush().unwrap(); + std::io::stdout().flush().map_err(PakkerError::IoError)?; let mut input = String::new(); - std::io::stdin().read_line(&mut input).unwrap(); + std::io::stdin() + .read_line(&mut input) + .map_err(PakkerError::IoError)?; if !input.trim().eq_ignore_ascii_case("y") { println!("Cancelled."); @@ -596,7 +596,7 @@ fn execute_sync() -> Result<(), PakkerError> { Ok(()) } -fn execute_promote(projects: Vec) -> Result<(), PakkerError> { +fn execute_promote(projects: &[String]) -> Result<(), PakkerError> { let config_dir = Path::new("."); let local_config = LocalConfig::load(config_dir)?; @@ -617,7 +617,7 @@ fn execute_promote(projects: Vec) -> Result<(), PakkerError> { let config = Config::load(config_dir)?; // Verify all projects exist - for project_arg in &projects { + for project_arg in projects { let found = config .projects .as_ref() @@ -635,7 +635,7 @@ fn execute_promote(projects: Vec) -> Result<(), PakkerError> { println!("automatically merged with parent projects during export."); println!(); println!("The following projects are already in pakku.json:"); - for project in &projects { + for project in projects { println!(" - {project}"); } println!(); diff --git a/src/cli/commands/import.rs b/src/cli/commands/import.rs index 12f3113..3f8eec6 100644 --- a/src/cli/commands/import.rs +++ b/src/cli/commands/import.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::{collections::HashMap, path::Path}; use crate::{ cli::ImportArgs, @@ -49,8 +49,8 @@ pub async fn execute( let file = std::fs::File::open(path)?; let mut archive = zip::ZipArchive::new(file)?; - let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new(".")); - let config_dir = config_path.parent().unwrap_or(Path::new(".")); + let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new(".")); + let config_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); if archive.by_name("modrinth.index.json").is_ok() { drop(archive); @@ -94,14 +94,15 @@ async fn import_modrinth( .unwrap_or("1.20.1") .to_string(); - let loader = - if let Some(fabric) = index["dependencies"]["fabric-loader"].as_str() { - ("fabric".to_string(), fabric.to_string()) - } else if let Some(forge) = index["dependencies"]["forge"].as_str() { - ("forge".to_string(), forge.to_string()) - } else { - ("fabric".to_string(), "latest".to_string()) - }; + let loader = index["dependencies"]["fabric-loader"].as_str().map_or_else( + || { + index["dependencies"]["forge"].as_str().map_or_else( + || ("fabric".to_string(), "latest".to_string()), + |forge| ("forge".to_string(), forge.to_string()), + ) + }, + |fabric| ("fabric".to_string(), fabric.to_string()), + ); let mut loaders = std::collections::HashMap::new(); loaders.insert(loader.0.clone(), loader.1); @@ -119,12 +120,10 @@ async fn import_modrinth( log::info!("Importing {} projects from modpack", files.len()); // Create platform client - let creds = crate::model::credentials::ResolvedCredentials::load().ok(); + let creds = crate::model::credentials::ResolvedCredentials::load(); let platform = create_platform( "modrinth", - creds - .as_ref() - .and_then(|c| c.modrinth_token().map(std::string::ToString::to_string)), + creds.modrinth_token().map(std::string::ToString::to_string), )?; for file_entry in files { @@ -184,7 +183,7 @@ async fn import_modrinth( overrides: vec!["overrides".to_string()], server_overrides: None, client_overrides: None, - paths: Default::default(), + paths: HashMap::default(), projects: None, export_profiles: None, export_server_side_projects_to_client: None, @@ -205,7 +204,9 @@ async fn import_modrinth( })?; if outpath.starts_with("overrides/") { - let target = outpath.strip_prefix("overrides/").unwrap(); + let Some(target) = outpath.strip_prefix("overrides/").ok() else { + continue; + }; if file.is_dir() { std::fs::create_dir_all(target)?; @@ -231,6 +232,8 @@ async fn import_curseforge( use zip::ZipArchive; + use crate::platform::create_platform; + let file = File::open(path)?; let mut archive = ZipArchive::new(file)?; @@ -283,7 +286,6 @@ async fn import_curseforge( log::info!("Importing {} projects from modpack", files.len()); // Create platform client - use crate::platform::create_platform; let curseforge_token = std::env::var("CURSEFORGE_TOKEN").ok(); let platform = create_platform("curseforge", curseforge_token)?; @@ -370,7 +372,7 @@ async fn import_curseforge( overrides: vec!["overrides".to_string()], server_overrides: None, client_overrides: None, - paths: Default::default(), + paths: HashMap::default(), projects: None, export_profiles: None, export_server_side_projects_to_client: None, @@ -393,7 +395,9 @@ async fn import_curseforge( })?; if outpath.starts_with(overrides_prefix) { - let target = outpath.strip_prefix(overrides_prefix).unwrap(); + let Some(target) = outpath.strip_prefix(overrides_prefix).ok() else { + continue; + }; if file.is_dir() { std::fs::create_dir_all(target)?; diff --git a/src/cli/commands/init.rs b/src/cli/commands/init.rs index 13858ef..9567b5d 100644 --- a/src/cli/commands/init.rs +++ b/src/cli/commands/init.rs @@ -12,7 +12,7 @@ use crate::{ }, }; -pub async fn execute( +pub fn execute( args: InitArgs, global_yes: bool, lockfile_path: &Path, @@ -125,7 +125,7 @@ pub async fn execute( }; // Save expects directory path, so get parent directory - let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new(".")); + let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new(".")); lockfile.save(lockfile_dir)?; let config = Config { @@ -143,7 +143,7 @@ pub async fn execute( file_count_preference: None, }; - let config_dir = config_path.parent().unwrap_or(Path::new(".")); + let config_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); config.save(config_dir)?; println!("Initialized new modpack '{name}' v{version}"); @@ -161,10 +161,8 @@ pub async fn execute( // Check if CurseForge API key is needed and prompt if interactive if is_interactive && (target == "curseforge" || target == "multiplatform") { - let credentials = ResolvedCredentials::load().ok(); - let has_cf_key = credentials - .as_ref() - .is_some_and(|c| c.curseforge_api_key().is_some()); + let credentials = ResolvedCredentials::load(); + let has_cf_key = credentials.curseforge_api_key().is_some(); if !has_cf_key { println!(); diff --git a/src/cli/commands/inspect.rs b/src/cli/commands/inspect.rs index 75378e0..3620661 100644 --- a/src/cli/commands/inspect.rs +++ b/src/cli/commands/inspect.rs @@ -9,13 +9,13 @@ use crate::{ model::{Config, LockFile, Project, ProjectFile}, }; -pub async fn execute( - projects: Vec, +pub fn execute( + projects: &[String], lockfile_path: &Path, config_path: &Path, ) -> Result<()> { - let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new(".")); - let config_dir = config_path.parent().unwrap_or(Path::new(".")); + let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new(".")); + let config_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); let lockfile = LockFile::load(lockfile_dir)?; let _config = Config::load(config_dir)?; @@ -172,15 +172,15 @@ fn display_project_inspection( lockfile: &LockFile, ) -> Result<()> { // Display project header panel - display_project_header(project)?; + display_project_header(project); // Display project files println!(); - display_project_files(&project.files, project)?; + display_project_files(&project.files, project); // Display properties println!(); - display_properties(project)?; + display_properties(project); // Display dependency tree println!(); @@ -191,7 +191,7 @@ fn display_project_inspection( Ok(()) } -fn display_project_header(project: &Project) -> Result<()> { +fn display_project_header(project: &Project) { let name = get_project_name(project); let default_slug = String::from("N/A"); let slug = project.slug.values().next().unwrap_or(&default_slug); @@ -213,7 +213,7 @@ fn display_project_header(project: &Project) -> Result<()> { let metadata = format!( "{} ({}) • {} • {}", slug, - project.id.keys().next().unwrap_or(&"unknown".to_string()), + project.id.keys().next().map_or("unknown", String::as_str), format!("{:?}", project.r#type).to_lowercase(), format!("{:?}", project.side).to_lowercase() ); @@ -224,17 +224,12 @@ fn display_project_header(project: &Project) -> Result<()> { ]); println!("{table}"); - - Ok(()) } -fn display_project_files( - files: &[ProjectFile], - project: &Project, -) -> Result<()> { +fn display_project_files(files: &[ProjectFile], project: &Project) { if files.is_empty() { println!("{}", "No files available".yellow()); - return Ok(()); + return; } println!("{}", "Project Files".cyan().bold()); @@ -255,13 +250,14 @@ fn display_project_files( // File path line with optional site URL let file_path = format!("{}={}", file.file_type, file.file_name); - let file_display = if let Some(site_url) = file.get_site_url(project) { - // Create hyperlink for the file - let hyperlink = crate::ui_utils::hyperlink(&site_url, &file_path); - format!("{hyperlink}:{status_text}") - } else { - format!("{file_path}:{status_text}") - }; + let file_display = file.get_site_url(project).map_or_else( + || format!("{file_path}:{status_text}"), + |site_url| { + // Create hyperlink for the file + let hyperlink = crate::ui_utils::hyperlink(&site_url, &file_path); + format!("{hyperlink}:{status_text}") + }, + ); table.add_row(vec![Cell::new(file_display).fg(if idx == 0 { Color::Green @@ -302,11 +298,9 @@ fn display_project_files( println!("{table}"); println!(); } - - Ok(()) } -fn display_properties(project: &Project) -> Result<()> { +fn display_properties(project: &Project) { println!("{}", "Properties".cyan().bold()); println!( @@ -338,8 +332,6 @@ fn display_properties(project: &Project) -> Result<()> { let aliases: Vec<_> = project.aliases.iter().cloned().collect(); println!(" {}={}", "aliases".yellow(), aliases.join(", ")); } - - Ok(()) } fn display_dependencies(project: &Project, lockfile: &LockFile) -> Result<()> { diff --git a/src/cli/commands/link.rs b/src/cli/commands/link.rs index 8f7fb6e..13a5247 100644 --- a/src/cli/commands/link.rs +++ b/src/cli/commands/link.rs @@ -6,11 +6,11 @@ use crate::{ model::LockFile, }; -pub fn execute(args: LinkArgs, lockfile_path: &Path) -> Result<()> { +pub fn execute(args: &LinkArgs, lockfile_path: &Path) -> Result<()> { log::info!("Linking {} -> {}", args.from, args.to); // Load expects directory path, so get parent directory - let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new(".")); + let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new(".")); let mut lockfile = LockFile::load(lockfile_dir)?; // Find projects diff --git a/src/cli/commands/ls.rs b/src/cli/commands/ls.rs index 4b9c1c3..37ed85e 100644 --- a/src/cli/commands/ls.rs +++ b/src/cli/commands/ls.rs @@ -14,9 +14,9 @@ fn truncate_name(name: &str, max_len: usize) -> String { } } -pub fn execute(args: LsArgs, lockfile_path: &Path) -> Result<()> { +pub fn execute(args: &LsArgs, lockfile_path: &Path) -> Result<()> { // Load expects directory path, so get parent directory - let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new(".")); + let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new(".")); let lockfile = LockFile::load(lockfile_dir)?; if lockfile.projects.is_empty() { diff --git a/src/cli/commands/remote.rs b/src/cli/commands/remote.rs index 393011a..d1a225a 100644 --- a/src/cli/commands/remote.rs +++ b/src/cli/commands/remote.rs @@ -29,11 +29,13 @@ pub async fn execute(args: RemoteArgs) -> Result<()> { // If no URL provided, show status if args.url.is_none() { - show_remote_status(&remote_path)?; + show_remote_status(&remote_path); return Ok(()); } - let url = args.url.unwrap(); + let url = args + .url + .ok_or_else(|| PakkerError::InvalidInput("URL is required".to_string()))?; log::info!("Installing modpack from: {url}"); // Clone or update repository @@ -90,10 +92,10 @@ pub async fn execute(args: RemoteArgs) -> Result<()> { Ok(()) } -fn show_remote_status(remote_path: &Path) -> Result<()> { +fn show_remote_status(remote_path: &Path) { if !remote_path.exists() { println!("No remote configured"); - return Ok(()); + return; } println!("Remote status:"); @@ -107,8 +109,6 @@ fn show_remote_status(remote_path: &Path) -> Result<()> { println!(" Commit: {}", &sha[..8]); } } - - Ok(()) } fn sync_overrides(remote_path: &Path, server_pack: bool) -> Result<()> { diff --git a/src/cli/commands/remote_update.rs b/src/cli/commands/remote_update.rs index 873dbe9..5f50bb9 100644 --- a/src/cli/commands/remote_update.rs +++ b/src/cli/commands/remote_update.rs @@ -6,7 +6,7 @@ use crate::{cli::RemoteUpdateArgs, error::PakkerError, git, model::Config}; /// /// This command updates the current modpack from its remote Git repository. /// It fetches the latest changes from the remote and syncs overrides. -pub async fn execute(args: RemoteUpdateArgs) -> Result<(), PakkerError> { +pub fn execute(args: &RemoteUpdateArgs) -> Result<(), PakkerError> { // Check if lockfile exists in current directory - if it does, we're in a // modpack directory and should not update remote (use regular update // instead) @@ -60,7 +60,7 @@ pub async fn execute(args: RemoteUpdateArgs) -> Result<(), PakkerError> { // Sync overrides from remote directory println!("Syncing overrides..."); - sync_overrides(&remote_dir).await?; + sync_overrides(&remote_dir)?; // Clean up remote directory std::fs::remove_dir_all(&remote_dir)?; @@ -71,7 +71,7 @@ pub async fn execute(args: RemoteUpdateArgs) -> Result<(), PakkerError> { } /// Sync override files from remote directory to current directory -async fn sync_overrides(remote_dir: &Path) -> Result<(), PakkerError> { +fn sync_overrides(remote_dir: &Path) -> Result<(), PakkerError> { let remote_config_path = remote_dir.join("pakku.json"); if !remote_config_path.exists() { return Ok(()); diff --git a/src/cli/commands/rm.rs b/src/cli/commands/rm.rs index 5189e59..8ae12d5 100644 --- a/src/cli/commands/rm.rs +++ b/src/cli/commands/rm.rs @@ -7,15 +7,15 @@ use crate::{ ui_utils::{prompt_typo_suggestion, prompt_yes_no}, }; -pub async fn execute( - args: RmArgs, +pub 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 lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new(".")); let mut lockfile = LockFile::load(lockfile_dir)?; // Determine which projects to remove diff --git a/src/cli/commands/set.rs b/src/cli/commands/set.rs index 11111c6..5ed8f4f 100644 --- a/src/cli/commands/set.rs +++ b/src/cli/commands/set.rs @@ -6,14 +6,14 @@ use crate::{ model::{Config, LockFile, ProjectSide, ProjectType, Target, UpdateStrategy}, }; -pub async fn execute( - args: SetArgs, +pub fn execute( + args: &SetArgs, lockfile_path: &Path, config_path: &Path, ) -> Result<(), PakkerError> { // 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(".")); + let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new(".")); + let config_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); let mut lockfile = LockFile::load(lockfile_dir)?; let config = Config::load(config_dir)?; @@ -61,7 +61,7 @@ pub async fn execute( } } - lockfile.mc_versions = mc_versions.clone(); + lockfile.mc_versions.clone_from(&mc_versions); println!("Set Minecraft versions to: {mc_versions:?}"); } @@ -101,7 +101,7 @@ pub async fn execute( } } - lockfile.loaders = loaders.clone(); + lockfile.loaders.clone_from(&loaders); println!("Set loaders to: {loaders:?}"); } diff --git a/src/cli/commands/status.rs b/src/cli/commands/status.rs index 1e157a8..fed7b13 100644 --- a/src/cli/commands/status.rs +++ b/src/cli/commands/status.rs @@ -17,8 +17,8 @@ pub async fn execute( lockfile_path: &Path, config_path: &Path, ) -> Result<()> { - let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new(".")); - let config_dir = config_path.parent().unwrap_or(Path::new(".")); + let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new(".")); + let config_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); let lockfile = LockFile::load(lockfile_dir)?; let config = Config::load(config_dir)?; @@ -67,7 +67,6 @@ pub async fn execute( } // Log info level summary - let _info_severity = ErrorSeverity::Info; log::info!( "Update check completed with {} warning(s) and {} error(s)", warnings.len(), @@ -138,6 +137,10 @@ struct FileUpdate { new_filename: String, } +#[expect( + clippy::expect_used, + reason = "progress bar template is a string literal and is always valid" +)] async fn check_updates_sequential( lockfile: &LockFile, ) -> Result<(Vec, Vec<(String, String)>)> { @@ -150,7 +153,7 @@ async fn check_updates_sequential( pb.set_style( ProgressStyle::default_bar() .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}") - .unwrap() + .expect("progress bar template is valid") .progress_chars("#>-"), ); pb.set_message("Checking for updates..."); @@ -160,8 +163,8 @@ async fn check_updates_sequential( .name .values() .next() - .unwrap_or(&"Unknown".to_string()) - .clone(); + .cloned() + .unwrap_or_else(|| "Unknown".to_string()); pb.set_message(format!("Checking {project_name}...")); match check_project_update(project, lockfile).await { @@ -184,6 +187,11 @@ async fn check_updates_sequential( Ok((updates, errors)) } +#[expect( + clippy::expect_used, + reason = "progress bar template and semaphore acquire are infallible in \ + this context" +)] async fn check_updates_parallel( lockfile: &LockFile, ) -> Result<(Vec, Vec<(String, String)>)> { @@ -196,7 +204,7 @@ async fn check_updates_parallel( pb.set_style( ProgressStyle::default_bar() .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}") - .unwrap() + .expect("progress bar template is valid") .progress_chars("#>-"), ); pb.set_message("Checking for updates (parallel)..."); @@ -208,7 +216,7 @@ async fn check_updates_parallel( let lockfile_clone = lockfile.clone(); futures.push(async move { - let _permit = sem.acquire().await.unwrap(); + let _permit = sem.acquire().await.expect("semaphore closed unexpectedly"); let result = check_project_update(&project, &lockfile_clone).await; pb_clone.inc(1); (project, result) @@ -230,8 +238,8 @@ async fn check_updates_parallel( .name .values() .next() - .unwrap_or(&"Unknown".to_string()) - .clone(); + .cloned() + .unwrap_or_else(|| "Unknown".to_string()); errors.push((project_name, e.to_string())); }, } @@ -260,37 +268,30 @@ async fn check_project_update( // Try each platform in project for platform_name in project.id.keys() { let api_key = get_api_key(platform_name); - let platform = match create_platform(platform_name, api_key) { - Ok(p) => p, - Err(_) => continue, + let Ok(platform) = create_platform(platform_name, api_key) else { + continue; }; let loaders: Vec = lockfile.loaders.keys().cloned().collect(); - match platform + if let Ok(updated_project) = platform .request_project_with_files(&slug, &lockfile.mc_versions, &loaders) .await { - Ok(updated_project) => { - // Compare files to detect updates - let file_updates = detect_file_updates(project, &updated_project); + // Compare files to detect updates + let file_updates = detect_file_updates(project, &updated_project); - if !file_updates.is_empty() { - return Ok(Some(ProjectUpdate { - slug: project.slug.clone(), - name: project.name.values().next().cloned().unwrap_or_default(), - project_type: format!("{:?}", project.r#type), - side: format!("{:?}", project.side), - file_updates, - })); - } + if !file_updates.is_empty() { + return Ok(Some(ProjectUpdate { + slug: project.slug.clone(), + name: project.name.values().next().cloned().unwrap_or_default(), + project_type: format!("{:?}", project.r#type), + side: format!("{:?}", project.side), + file_updates, + })); + } - return Ok(None); // No updates - }, - Err(_) => { - // Try next platform - continue; - }, + return Ok(None); // No updates } } diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index 3dac152..1963ba7 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -19,6 +19,10 @@ enum SyncChange { Removal(String), // project_pakku_id } +#[expect( + clippy::expect_used, + reason = "spinner template is a string literal and is always valid" +)] pub async fn execute( args: SyncArgs, global_yes: bool, @@ -27,14 +31,14 @@ pub async fn execute( ) -> Result<()> { log::info!("Synchronizing with lockfile"); - let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new(".")); - let config_dir = config_path.parent().unwrap_or(Path::new(".")); + let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new(".")); + let config_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); let mut lockfile = LockFile::load(lockfile_dir)?; let config = Config::load(config_dir)?; // Detect changes - let changes = detect_changes(&lockfile, &config)?; + let changes = detect_changes(&lockfile, &config); if changes.is_empty() { println!("✓ Everything is in sync"); @@ -59,7 +63,7 @@ pub async fn execute( spinner.set_style( ProgressStyle::default_spinner() .template("{spinner:.green} {msg}") - .unwrap(), + .expect("spinner template is valid"), ); if no_filter || args.additions { @@ -145,10 +149,7 @@ pub async fn execute( Ok(()) } -fn detect_changes( - lockfile: &LockFile, - config: &Config, -) -> Result> { +fn detect_changes(lockfile: &LockFile, config: &Config) -> Vec { let mut changes = Vec::new(); // Get paths for each project type @@ -177,23 +178,26 @@ fn detect_changes( && ext == "jar" && !lockfile_files.contains_key(&path) { - let name = path.file_name().unwrap().to_string_lossy().to_string(); + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); changes.push(SyncChange::Addition(path, name)); } } } // Check for removals (projects in lockfile but files missing) - let filesystem_files: HashSet<_> = - if let Ok(entries) = fs::read_dir(mods_path) { + let filesystem_files: HashSet<_> = fs::read_dir(mods_path).map_or_else( + |_| HashSet::new(), + |entries| { entries .flatten() .map(|e| e.path()) .filter(|p| p.is_file()) .collect() - } else { - HashSet::new() - }; + }, + ); for (lockfile_path, pakku_id) in &lockfile_files { if !filesystem_files.contains(lockfile_path) { @@ -201,7 +205,7 @@ fn detect_changes( } } - Ok(changes) + changes } async fn add_file_to_lockfile( @@ -209,14 +213,14 @@ async fn add_file_to_lockfile( file_path: &Path, _config: &Config, ) -> Result<()> { + use sha1::Digest; + // Try to identify the file by hash lookup let modrinth = ModrinthPlatform::new(); let curseforge = CurseForgePlatform::new(None); // Compute file hash let file_data = fs::read(file_path)?; - // Compute SHA-1 hash from file bytes - use sha1::Digest; let mut hasher = sha1::Sha1::new(); hasher.update(&file_data); let hash = crate::utils::hash::hash_to_hex(hasher.finalize().as_slice()); diff --git a/src/cli/commands/unlink.rs b/src/cli/commands/unlink.rs index f43951d..f623641 100644 --- a/src/cli/commands/unlink.rs +++ b/src/cli/commands/unlink.rs @@ -6,11 +6,11 @@ use crate::{ model::LockFile, }; -pub fn execute(args: UnlinkArgs, lockfile_path: &Path) -> Result<()> { +pub fn execute(args: &UnlinkArgs, lockfile_path: &Path) -> Result<()> { log::info!("Unlinking {} -> {}", args.from, args.to); // Load expects directory path, so get parent directory - let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new(".")); + let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new(".")); let mut lockfile = LockFile::load(lockfile_dir)?; // Find projects diff --git a/src/cli/commands/update.rs b/src/cli/commands/update.rs index e532342..1922bad 100644 --- a/src/cli/commands/update.rs +++ b/src/cli/commands/update.rs @@ -10,6 +10,10 @@ use crate::{ utils::FlexVer, }; +#[expect( + clippy::expect_used, + reason = "progress bar template is a string literal and is always valid" +)] pub async fn execute( args: UpdateArgs, global_yes: bool, @@ -18,14 +22,14 @@ pub async fn execute( ) -> 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(".")); + let lockfile_dir = lockfile_path.parent().unwrap_or_else(|| Path::new(".")); + let config_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); let mut lockfile = LockFile::load(lockfile_dir)?; let _config = Config::load(config_dir)?; // Create platforms - let platforms = super::add::create_all_platforms()?; + let platforms = super::add::create_all_platforms(); // Collect all known project identifiers for typo suggestions let all_slugs: Vec = lockfile @@ -83,7 +87,7 @@ pub async fn execute( pb.set_style( ProgressStyle::default_bar() .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}") - .unwrap() + .expect("progress bar template is valid") .progress_chars("#>-"), ); @@ -152,9 +156,12 @@ pub async fn execute( } // Clone data needed for comparisons to avoid borrow issues - let new_file_id = updated_project.files.first().unwrap().id.clone(); - let new_file_name = - updated_project.files.first().unwrap().file_name.clone(); + let first_file = updated_project + .files + .first() + .ok_or_else(|| PakkerError::InvalidProject("No files found".into()))?; + let new_file_id = first_file.id.clone(); + let new_file_name = first_file.file_name.clone(); let old_file_name = old_file.file_name.clone(); let project_name = old_project.get_name(); @@ -205,7 +212,12 @@ pub async fn execute( } if should_update { - let selected_file = updated_project.files.first().unwrap(); + let selected_file = + updated_project.files.first().ok_or_else(|| { + PakkerError::InvalidProject( + "No files found after selection".into(), + ) + })?; pb.println(format!( " {} -> {}", old_file_name, selected_file.file_name diff --git a/src/error.rs b/src/error.rs index 3a97ffd..2455aaf 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,5 @@ +use std::fmt::Write; + use thiserror::Error; pub type Result = std::result::Result; @@ -11,6 +13,7 @@ pub enum ErrorSeverity { /// Warning - operation can continue but may have issues Warning, /// Info - informational message + #[expect(dead_code, reason = "reserved for future use")] Info, } @@ -177,7 +180,7 @@ fn format_multiple_errors(errors: &[PakkerError]) -> String { let mut msg = format!("{} errors occurred:\n", errors.len()); for (idx, error) in errors.iter().enumerate() { - msg.push_str(&format!(" {}. {}\n", idx + 1, error)); + let _ = writeln!(msg, " {}. {}", idx + 1, error); } msg } diff --git a/src/export.rs b/src/export.rs index 2bd1bbf..a82602e 100644 --- a/src/export.rs +++ b/src/export.rs @@ -36,8 +36,9 @@ impl Exporter { /// /// Returns successfully exported files. If any profile failed (non-skip), /// returns an error after attempting all profiles. + #[expect(clippy::future_not_send, reason = "not required to be Send")] pub async fn export_all_profiles( - &mut self, + &self, lockfile: &LockFile, config: &Config, output_path: &Path, @@ -99,8 +100,13 @@ impl Exporter { } /// Export modpack using specified profile + #[expect(clippy::future_not_send, reason = "not required to be Send")] + #[expect( + clippy::expect_used, + reason = "spinner template string is a literal and always valid" + )] pub async fn export( - &mut self, + &self, profile_name: &str, lockfile: &LockFile, config: &Config, @@ -110,7 +116,7 @@ impl Exporter { spinner.set_style( ProgressStyle::default_spinner() .template("{spinner:.cyan} {msg}") - .unwrap(), + .expect("spinner template is valid"), ); spinner.set_message(format!("Preparing {profile_name} export...")); @@ -175,7 +181,7 @@ impl Exporter { spinner.set_message("Creating archive..."); // Package export let output_file = - self.package_export(export_dir, output_path, profile_name, config)?; + Self::package_export(export_dir, output_path, profile_name, config)?; // Cleanup drop(temp_dir); @@ -187,7 +193,6 @@ impl Exporter { /// Package export directory into final format fn package_export( - &self, export_dir: &Path, output_path: &Path, profile_name: &str, @@ -224,7 +229,7 @@ impl Exporter { .unix_permissions(0o755); // Add all files from export directory - self.add_directory_to_zip(&mut zip, export_dir, export_dir, options)?; + Self::add_directory_to_zip(&mut zip, export_dir, export_dir, options)?; zip.finish()?; @@ -233,7 +238,6 @@ impl Exporter { /// Recursively add directory to zip fn add_directory_to_zip( - &self, zip: &mut zip::ZipWriter, base_path: &Path, current_path: &Path, @@ -255,7 +259,7 @@ impl Exporter { relative_path.to_string_lossy().to_string(), options, )?; - self.add_directory_to_zip(zip, base_path, &path, options)?; + Self::add_directory_to_zip(zip, base_path, &path, options)?; } } diff --git a/src/export/profile_config.rs b/src/export/profile_config.rs index b51758c..789a49d 100644 --- a/src/export/profile_config.rs +++ b/src/export/profile_config.rs @@ -66,7 +66,7 @@ impl ProfileConfig { self .server_overrides .as_deref() - .or(global_server_overrides.map(std::vec::Vec::as_slice)) + .or_else(|| global_server_overrides.map(std::vec::Vec::as_slice)) } /// Get effective client override paths, falling back to global config @@ -77,7 +77,7 @@ impl ProfileConfig { self .client_overrides .as_deref() - .or(global_client_overrides.map(std::vec::Vec::as_slice)) + .or_else(|| global_client_overrides.map(std::vec::Vec::as_slice)) } /// Get default config for `CurseForge` profile diff --git a/src/export/rules.rs b/src/export/rules.rs index bfdf040..c82ab96 100644 --- a/src/export/rules.rs +++ b/src/export/rules.rs @@ -54,7 +54,7 @@ impl Effect for CopyProjectFilesEffect { use crate::model::ResolvedCredentials; // Resolve credentials (env -> keyring -> Pakker file -> Pakku file). - let credentials = ResolvedCredentials::load()?; + let credentials = ResolvedCredentials::load(); let curseforge_key = credentials.curseforge_api_key().map(ToOwned::to_owned); let modrinth_token = credentials.modrinth_token().map(ToOwned::to_owned); @@ -66,14 +66,13 @@ impl Effect for CopyProjectFilesEffect { if let Some(file) = project.files.first() { // Get the target directory based on project type and paths config - let type_dir = get_project_type_dir(&project.r#type, &context.config); + let type_dir = get_project_type_dir(project.r#type, &context.config); // Handle subpath if specified - let target_subdir = if let Some(subpath) = &project.subpath { - PathBuf::from(&type_dir).join(subpath) - } else { - PathBuf::from(&type_dir) - }; + let target_subdir = project.subpath.as_ref().map_or_else( + || PathBuf::from(&type_dir), + |subpath| PathBuf::from(&type_dir).join(subpath), + ); let export_dir = context.export_path.join(&target_subdir); fs::create_dir_all(&export_dir)?; @@ -204,7 +203,15 @@ async fn download_file( let attempts: usize = 5; for attempt in 1..=attempts { - let response = request_builder.try_clone().unwrap().send().await; + let response = request_builder + .try_clone() + .ok_or_else(|| { + crate::error::PakkerError::InternalError( + "Failed to clone request builder".into(), + ) + })? + .send() + .await; match response { Ok(resp) if resp.status().is_success() => { @@ -295,11 +302,12 @@ impl Effect for CopyOverridesEffect { async fn execute(&self, context: &RuleContext) -> Result<()> { // Use profile-specific overrides if available, otherwise use global config - let overrides = if let Some(profile_config) = &context.profile_config { - profile_config.get_overrides(&context.config.overrides) - } else { - &context.config.overrides - }; + let overrides = context + .profile_config + .as_ref() + .map_or(context.config.overrides.as_slice(), |profile_config| { + profile_config.get_overrides(&context.config.overrides) + }); // Expand any glob patterns in override paths let expanded_paths = expand_override_globs(&context.base_path, overrides); @@ -342,13 +350,13 @@ impl Effect for CopyServerOverridesEffect { async fn execute(&self, context: &RuleContext) -> Result<()> { // Use profile-specific server overrides if available, otherwise use global // config - let server_overrides = if let Some(profile_config) = &context.profile_config - { - profile_config - .get_server_overrides(context.config.server_overrides.as_ref()) - } else { - context.config.server_overrides.as_deref() - }; + let server_overrides = context.profile_config.as_ref().map_or( + context.config.server_overrides.as_deref(), + |profile_config| { + profile_config + .get_server_overrides(context.config.server_overrides.as_ref()) + }, + ); if let Some(overrides) = server_overrides { // Expand any glob patterns in override paths @@ -393,13 +401,13 @@ impl Effect for CopyClientOverridesEffect { async fn execute(&self, context: &RuleContext) -> Result<()> { // Use profile-specific client overrides if available, otherwise use global // config - let client_overrides = if let Some(profile_config) = &context.profile_config - { - profile_config - .get_client_overrides(context.config.client_overrides.as_ref()) - } else { - context.config.client_overrides.as_deref() - }; + let client_overrides = context.profile_config.as_ref().map_or( + context.config.client_overrides.as_deref(), + |profile_config| { + profile_config + .get_client_overrides(context.config.client_overrides.as_ref()) + }, + ); if let Some(overrides) = client_overrides { // Expand any glob patterns in override paths @@ -459,7 +467,7 @@ impl Effect for FilterClientOnlyEffect { && let Some(file) = project.files.first() { // Get the target directory based on project type and paths config - let type_dir = get_project_type_dir(&project.r#type, &context.config); + let type_dir = get_project_type_dir(project.r#type, &context.config); let project_dir = context.export_path.join(&type_dir); let file_path = project_dir.join(&file.file_name); @@ -514,7 +522,7 @@ impl Effect for FilterServerOnlyEffect { && let Some(file) = project.files.first() { // Get the target directory based on project type and paths config - let type_dir = get_project_type_dir(&project.r#type, &context.config); + let type_dir = get_project_type_dir(project.r#type, &context.config); let project_dir = context.export_path.join(&type_dir); let file_path = project_dir.join(&file.file_name); @@ -573,7 +581,7 @@ impl Effect for FilterNonRedistributableEffect { && let Some(file) = project.files.first() { // Get the target directory based on project type and paths config - let type_dir = get_project_type_dir(&project.r#type, &context.config); + let type_dir = get_project_type_dir(project.r#type, &context.config); let project_dir = context.export_path.join(&type_dir); let file_path = project_dir.join(&file.file_name); @@ -668,7 +676,7 @@ fn generate_curseforge_manifest(context: &RuleContext) -> Result { let manifest = json!({ "minecraft": { - "version": context.lockfile.mc_versions.first().unwrap_or(&"1.20.1".to_string()), + "version": context.lockfile.mc_versions.first().map_or("1.20.1", String::as_str), "modLoaders": context.lockfile.loaders.iter().map(|(name, version)| { json!({ "id": format!("{}-{}", name, version), @@ -736,7 +744,7 @@ fn generate_modrinth_manifest(context: &RuleContext) -> Result { .lockfile .mc_versions .first() - .unwrap_or(&"1.20.1".to_string()) + .map_or("1.20.1", String::as_str) ), ); @@ -781,7 +789,7 @@ fn copy_recursive( /// Get the target directory for a project type, respecting the paths config. /// Falls back to default directories if not configured. -fn get_project_type_dir(project_type: &ProjectType, config: &Config) -> String { +fn get_project_type_dir(project_type: ProjectType, config: &Config) -> String { // Check if there's a custom path configured for this project type let type_key = project_type.to_string(); if let Some(custom_path) = config.paths.get(&type_key) { @@ -881,7 +889,7 @@ impl Effect for FilterByPlatformEffect { if let Some(file) = project.files.first() { // Get the target directory based on project type and paths config let type_dir = - get_project_type_dir(&project.r#type, &context.config); + get_project_type_dir(project.r#type, &context.config); let project_dir = context.export_path.join(&type_dir); let file_path = project_dir.join(&file.file_name); @@ -942,13 +950,10 @@ impl Effect for MissingProjectsAsOverridesEffect { async fn execute(&self, context: &RuleContext) -> Result<()> { use crate::model::ResolvedCredentials; - let credentials = ResolvedCredentials::load().ok(); - let curseforge_key = credentials - .as_ref() - .and_then(|c| c.curseforge_api_key().map(ToOwned::to_owned)); - let modrinth_token = credentials - .as_ref() - .and_then(|c| c.modrinth_token().map(ToOwned::to_owned)); + let credentials = ResolvedCredentials::load(); + let curseforge_key = + credentials.curseforge_api_key().map(ToOwned::to_owned); + let modrinth_token = credentials.modrinth_token().map(ToOwned::to_owned); for project in &context.lockfile.projects { if !project.export { @@ -977,7 +982,7 @@ impl Effect for MissingProjectsAsOverridesEffect { // Download to overrides directory let overrides_dir = context.export_path.join("overrides"); - let type_dir = get_project_type_dir(&project.r#type, &context.config); + let type_dir = get_project_type_dir(project.r#type, &context.config); let target_dir = overrides_dir.join(&type_dir); fs::create_dir_all(&target_dir)?; @@ -1128,11 +1133,6 @@ fn process_text_files( dir: &std::path::Path, replacements: &std::collections::HashMap<&str, String>, ) -> Result<()> { - if !dir.exists() { - return Ok(()); - } - - // File extensions that should be processed for text replacement const TEXT_EXTENSIONS: &[&str] = &[ "txt", "md", @@ -1150,6 +1150,10 @@ fn process_text_files( "xml", ]; + if !dir.exists() { + return Ok(()); + } + for entry in walkdir::WalkDir::new(dir) .into_iter() .filter_map(std::result::Result::ok) @@ -1170,9 +1174,8 @@ fn process_text_files( } // Read file content - let content = match fs::read_to_string(path) { - Ok(c) => c, - Err(_) => continue, // Skip binary files or unreadable files + let Ok(content) = fs::read_to_string(path) else { + continue; // Skip binary files or unreadable files }; // Check if any replacements are needed @@ -1366,20 +1369,20 @@ mod tests { file_count_preference: None, }; - assert_eq!(get_project_type_dir(&ProjectType::Mod, &config), "mods"); + assert_eq!(get_project_type_dir(ProjectType::Mod, &config), "mods"); assert_eq!( - get_project_type_dir(&ProjectType::ResourcePack, &config), + get_project_type_dir(ProjectType::ResourcePack, &config), "resourcepacks" ); assert_eq!( - get_project_type_dir(&ProjectType::DataPack, &config), + get_project_type_dir(ProjectType::DataPack, &config), "datapacks" ); assert_eq!( - get_project_type_dir(&ProjectType::Shader, &config), + get_project_type_dir(ProjectType::Shader, &config), "shaderpacks" ); - assert_eq!(get_project_type_dir(&ProjectType::World, &config), "saves"); + assert_eq!(get_project_type_dir(ProjectType::World, &config), "saves"); } #[test] @@ -1404,16 +1407,16 @@ mod tests { }; assert_eq!( - get_project_type_dir(&ProjectType::Mod, &config), + get_project_type_dir(ProjectType::Mod, &config), "custom-mods" ); assert_eq!( - get_project_type_dir(&ProjectType::ResourcePack, &config), + get_project_type_dir(ProjectType::ResourcePack, &config), "custom-rp" ); // Non-customized type should use default assert_eq!( - get_project_type_dir(&ProjectType::Shader, &config), + get_project_type_dir(ProjectType::Shader, &config), "shaderpacks" ); } diff --git a/src/fetch.rs b/src/fetch.rs index 40196d5..7ea8765 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -50,6 +50,10 @@ impl Fetcher { } /// Fetch all project files according to lockfile with parallel downloads + #[expect( + clippy::expect_used, + reason = "progress bar template string is a literal and always valid" + )] pub async fn fetch_all( &self, lockfile: &LockFile, @@ -71,7 +75,7 @@ impl Fetcher { overall_bar.set_style( ProgressStyle::default_bar() .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}") - .unwrap() + .expect("progress bar template is valid") .progress_chars("#>-"), ); overall_bar.set_message("Fetching projects..."); @@ -180,23 +184,23 @@ impl Fetcher { let project_dirs = [ ( "mod", - self.get_default_path(&crate::model::ProjectType::Mod), + Self::get_default_path(crate::model::ProjectType::Mod), ), ( "resource-pack", - self.get_default_path(&crate::model::ProjectType::ResourcePack), + Self::get_default_path(crate::model::ProjectType::ResourcePack), ), ( "shader", - self.get_default_path(&crate::model::ProjectType::Shader), + Self::get_default_path(crate::model::ProjectType::Shader), ), ( "data-pack", - self.get_default_path(&crate::model::ProjectType::DataPack), + Self::get_default_path(crate::model::ProjectType::DataPack), ), ( "world", - self.get_default_path(&crate::model::ProjectType::World), + Self::get_default_path(crate::model::ProjectType::World), ), ]; @@ -219,9 +223,8 @@ impl Fetcher { continue; } - let entries = match fs::read_dir(&dir) { - Ok(e) => e, - Err(_) => continue, + let Ok(entries) = fs::read_dir(&dir) else { + continue; }; for entry in entries.flatten() { @@ -241,7 +244,10 @@ impl Fetcher { } // Skip non-jar files (might be configs, etc.) - if !file_name.ends_with(".jar") { + if !std::path::Path::new(&file_name) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("jar")) + { continue; } @@ -279,7 +285,7 @@ impl Fetcher { config: &Config, ) -> Result<()> { // Select the best file for this project - let file = self.select_best_file(project, lockfile)?; + let file = Self::select_best_file(project, lockfile)?; // Determine target path let target_path = self.get_target_path(project, file, config); @@ -314,8 +320,11 @@ impl Fetcher { } /// Select the best file for a project based on constraints + #[expect( + clippy::expect_used, + reason = "compatible_files is checked to be non-empty above" + )] fn select_best_file<'a>( - &self, project: &'a Project, lockfile: &LockFile, ) -> Result<&'a ProjectFile> { @@ -338,7 +347,9 @@ impl Fetcher { let best = if project.update_strategy == UpdateStrategy::FlexVer { let mut sorted: Vec<_> = compatible_files.iter().collect(); sorted.sort_by(|a, b| FlexVer(&b.file_name).cmp(&FlexVer(&a.file_name))); - *sorted.first().unwrap() + *sorted + .first() + .expect("compatible_files is non-empty, checked above") } else { // Prefer release over beta over alpha, then by date published compatible_files @@ -351,7 +362,7 @@ impl Fetcher { }; (type_priority, &f.date_published) }) - .unwrap() + .expect("compatible_files is non-empty, checked above") }; Ok(best) @@ -371,7 +382,7 @@ impl Fetcher { path.push(custom_path); } else { // Default path based on project type - path.push(self.get_default_path(&project.r#type)); + path.push(Self::get_default_path(project.r#type)); } // Add subpath if specified @@ -385,9 +396,8 @@ impl Fetcher { /// Get default path for project type const fn get_default_path( - &self, - project_type: &crate::model::ProjectType, - ) -> &str { + project_type: crate::model::ProjectType, + ) -> &'static str { match project_type { crate::model::ProjectType::Mod => "mods", crate::model::ProjectType::ResourcePack => "resourcepacks", @@ -454,14 +464,14 @@ impl Fetcher { } // Copy override files to target locations - self.copy_recursive(&source, &self.base_path)?; + Self::copy_recursive(&source, &self.base_path)?; } Ok(()) } /// Copy directory recursively - fn copy_recursive(&self, source: &Path, dest: &Path) -> Result<()> { + fn copy_recursive(source: &Path, dest: &Path) -> Result<()> { if source.is_file() { fs::copy(source, dest)?; } else if source.is_dir() { @@ -469,7 +479,7 @@ impl Fetcher { for entry in fs::read_dir(source)? { let entry = entry?; let target = dest.join(entry.file_name()); - self.copy_recursive(&entry.path(), &target)?; + Self::copy_recursive(&entry.path(), &target)?; } } diff --git a/src/git/mod.rs b/src/git/mod.rs index a0a1ba6..941ddbf 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -12,6 +12,9 @@ use git2::{ use crate::error::{PakkerError, Result}; +type ProgressCallback = + Option) + 'static>>; + /// Check if a directory is a Git repository pub fn is_git_repository>(path: P) -> bool { Repository::open(path).is_ok() @@ -65,9 +68,7 @@ pub fn clone_repository>( url: &str, target_path: P, ref_name: &str, - progress_callback: Option< - Box) + 'static>, - >, + progress_callback: ProgressCallback, ) -> Result { let target_path = target_path.as_ref(); @@ -147,9 +148,7 @@ pub fn fetch_updates>( path: P, remote_name: &str, ref_name: &str, - progress_callback: Option< - Box) + 'static>, - >, + progress_callback: ProgressCallback, ) -> Result<()> { let repo = Repository::open(path)?; let mut remote = repo.find_remote(remote_name).map_err(|e| { diff --git a/src/http.rs b/src/http.rs index 789c7b5..78c13a7 100644 --- a/src/http.rs +++ b/src/http.rs @@ -8,6 +8,11 @@ use reqwest::Client; /// /// Panics if the HTTP client cannot be built, which should only happen in /// extreme cases like OOM or broken TLS configuration. +#[expect( + clippy::expect_used, + reason = "HTTP client build failure is unrecoverable - only fails under \ + extreme system resource exhaustion" +)] pub fn create_http_client() -> Client { Client::builder() .pool_max_idle_per_host(10) diff --git a/src/ipc.rs b/src/ipc.rs index 959ead8..f2c80d9 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -63,7 +63,7 @@ pub struct OngoingOperation { pub status: OperationStatus, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum OperationType { Fetch, @@ -99,11 +99,10 @@ impl IpcCoordinator { /// Get the base IPC directory in tmpfs fn get_ipc_base_dir() -> PathBuf { // Use XDG_RUNTIME_DIR if available, otherwise fallback to /tmp - if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") { - PathBuf::from(runtime).join("pakker") - } else { - PathBuf::from("/tmp/pakker") - } + std::env::var("XDG_RUNTIME_DIR").map_or_else( + |_| PathBuf::from("/tmp/pakker"), + |runtime| PathBuf::from(runtime).join("pakker"), + ) } /// Extract modpack hash from pakku.json's parentLockHash field. @@ -181,7 +180,7 @@ impl IpcCoordinator { /// Acquire an exclusive advisory lock on the ops file for atomic operations. /// Returns a guard that releases the lock on drop. fn lock_ops_file(&self) -> Result { - log::debug!("Acquiring file lock on {:?}", self.ops_file); + log::debug!("Acquiring file lock on {}", self.ops_file.display()); // Open or create the ops file with read/write access let file = OpenOptions::new() @@ -200,14 +199,17 @@ impl IpcCoordinator { // Acquire exclusive lock using flock unsafe { if flock(file.as_raw_fd(), LOCK_EX) != 0 { - log::warn!("Failed to acquire file lock on {:?}", self.ops_file); + log::warn!( + "Failed to acquire file lock on {}", + self.ops_file.display() + ); return Err(IpcError::InvalidFormat( "failed to acquire file lock".to_string(), )); } } - log::debug!("File lock acquired on {:?}", self.ops_file); + log::debug!("File lock acquired on {}", self.ops_file.display()); // Return a guard that releases the lock on drop Ok(FileLock { file }) @@ -435,7 +437,7 @@ impl IpcCoordinator { } impl OperationType { - pub const fn as_str(&self) -> &'static str { + pub const fn as_str(self) -> &'static str { match self { Self::Fetch => "fetch", Self::Export => "export", diff --git a/src/main.rs b/src/main.rs index a322b4c..d451206 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,11 @@ -// 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)] +#![expect( + clippy::multiple_crate_versions, + reason = "transitive dependency version conflicts from upstream crates" +)] +#![expect( + clippy::cargo_common_metadata, + reason = "license and repository not yet configured" +)] mod cli; mod error; @@ -78,7 +81,6 @@ async fn main() -> Result<(), PakkerError> { &lockfile_path, &config_path, ) - .await }, Commands::Import(args) => { cli::commands::import::execute( @@ -118,8 +120,12 @@ async fn main() -> Result<(), PakkerError> { .await }, Commands::Rm(args) => { - cli::commands::rm::execute(args, global_yes, &lockfile_path, &config_path) - .await + cli::commands::rm::execute( + &args, + global_yes, + &lockfile_path, + &config_path, + ) }, Commands::Update(args) => { cli::commands::update::execute( @@ -130,15 +136,15 @@ async fn main() -> Result<(), PakkerError> { ) .await }, - Commands::Ls(args) => cli::commands::ls::execute(args, &lockfile_path), + Commands::Ls(args) => cli::commands::ls::execute(&args, &lockfile_path), Commands::Set(args) => { - cli::commands::set::execute(args, &lockfile_path, &config_path).await + cli::commands::set::execute(&args, &lockfile_path, &config_path) }, - Commands::Link(args) => cli::commands::link::execute(args, &lockfile_path), + Commands::Link(args) => cli::commands::link::execute(&args, &lockfile_path), Commands::Unlink(args) => { - cli::commands::unlink::execute(args, &lockfile_path) + cli::commands::unlink::execute(&args, &lockfile_path) }, - Commands::Diff(args) => cli::commands::diff::execute(args, &lockfile_path), + Commands::Diff(args) => cli::commands::diff::execute(&args, &lockfile_path), Commands::Fetch(args) => { cli::commands::fetch::execute(args, &lockfile_path, &config_path).await }, @@ -156,7 +162,7 @@ async fn main() -> Result<(), PakkerError> { }, Commands::Remote(args) => cli::commands::remote::execute(args).await, Commands::RemoteUpdate(args) => { - cli::commands::remote_update::execute(args).await + cli::commands::remote_update::execute(&args) }, Commands::Status(args) => { cli::commands::status::execute( @@ -169,11 +175,10 @@ async fn main() -> Result<(), PakkerError> { }, Commands::Inspect(args) => { cli::commands::inspect::execute( - args.projects, + &args.projects, &lockfile_path, &config_path, ) - .await }, Commands::Credentials(args) => { match args.subcommand { @@ -199,7 +204,7 @@ async fn main() -> Result<(), PakkerError> { cli::commands::cfg_prj::execute( &config_path, &lockfile_path, - prj_args.project, + &prj_args.project, prj_args.r#type.as_deref(), prj_args.side.as_deref(), prj_args.update_strategy.as_deref(), diff --git a/src/model/credentials.rs b/src/model/credentials.rs index f577769..98a1424 100644 --- a/src/model/credentials.rs +++ b/src/model/credentials.rs @@ -155,11 +155,11 @@ pub struct ResolvedCredentials { } impl ResolvedCredentials { - pub fn load() -> Result { + pub fn load() -> Self { let pakker_file = PakkerCredentialsFile::load().ok(); let pakku_file = PakkerCompatCredentialsFile::load().ok(); - Ok(Self { + Self { curseforge_api_key: resolve_secret( "PAKKER_CURSEFORGE_API_KEY", "curseforge_api_key", @@ -169,13 +169,13 @@ impl ResolvedCredentials { pakku_file .as_ref() .and_then(|f| f.curseforge_api_key.clone()), - )?, + ), modrinth_token: resolve_secret( "PAKKER_MODRINTH_TOKEN", "modrinth_token", pakker_file.as_ref().and_then(|f| f.modrinth_token.clone()), None, - )?, + ), github_access_token: resolve_secret( "PAKKER_GITHUB_TOKEN", "github_access_token", @@ -185,8 +185,8 @@ impl ResolvedCredentials { pakku_file .as_ref() .and_then(|f| f.github_access_token.clone()), - )?, - }) + ), + } } pub fn curseforge_api_key(&self) -> Option<&str> { @@ -226,28 +226,26 @@ fn resolve_secret( keyring_entry: &str, pakker_file_value: Option, pakku_file_value: Option, -) -> Result> { +) -> Option<(String, CredentialsSource)> { if let Ok(v) = std::env::var(env_key) && !v.trim().is_empty() { - return Ok(Some((v.trim().to_string(), CredentialsSource::Env))); + return Some((v.trim().to_string(), CredentialsSource::Env)); } if let Ok(v) = get_keyring_secret(keyring_entry) && !v.trim().is_empty() { - return Ok(Some((v.trim().to_string(), CredentialsSource::Keyring))); + return Some((v.trim().to_string(), CredentialsSource::Keyring)); } if let Some(v) = pakker_file_value.filter(|v| !v.trim().is_empty()) { - return Ok(Some((v, CredentialsSource::PakkerFile))); + return Some((v, CredentialsSource::PakkerFile)); } - Ok( - pakku_file_value - .filter(|v| !v.trim().is_empty()) - .map(|v| (v, CredentialsSource::PakkerFile)), - ) + pakku_file_value + .filter(|v| !v.trim().is_empty()) + .map(|v| (v, CredentialsSource::PakkerFile)) } fn get_keyring_secret( @@ -279,8 +277,7 @@ fn delete_keyring_secret(entry: &str) -> Result<()> { })?; match e.delete_credential() { - Ok(()) => Ok(()), - Err(keyring::Error::NoEntry) => Ok(()), + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), Err(e) => { Err(PakkerError::InternalError(format!( "Failed to delete keyring entry {entry}: {e}" diff --git a/src/model/project.rs b/src/model/project.rs index b5d8deb..a9c7469 100644 --- a/src/model/project.rs +++ b/src/model/project.rs @@ -56,14 +56,26 @@ const fn default_redistributable() -> bool { true } +#[expect( + clippy::trivially_copy_pass_by_ref, + reason = "required by serde skip_serializing_if which expects fn(&T) -> bool" +)] const fn is_default_update_strategy(strategy: &UpdateStrategy) -> bool { matches!(strategy, UpdateStrategy::Latest) } +#[expect( + clippy::trivially_copy_pass_by_ref, + reason = "required by serde skip_serializing_if which expects fn(&T) -> bool" +)] const fn is_default_redistributable(redistributable: &bool) -> bool { *redistributable } +#[expect( + clippy::trivially_copy_pass_by_ref, + reason = "required by serde skip_serializing_if which expects fn(&T) -> bool" +)] const fn is_default_export(export: &bool) -> bool { *export } @@ -233,7 +245,7 @@ impl Project { id, update_strategy: self.update_strategy, redistributable: self.redistributable && other.redistributable, - subpath: self.subpath.clone().or(other.subpath.clone()), + subpath: self.subpath.clone().or_else(|| other.subpath.clone()), aliases, export: if self.export { self.export diff --git a/src/platform.rs b/src/platform.rs index 3c26574..cf89667 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -53,7 +53,7 @@ fn create_client( }, "github" => { Ok(Box::new(GitHubPlatform::with_client( - get_http_client(), + &get_http_client(), api_key, ))) }, diff --git a/src/platform/curseforge.rs b/src/platform/curseforge.rs index fd6797f..6629045 100644 --- a/src/platform/curseforge.rs +++ b/src/platform/curseforge.rs @@ -32,7 +32,10 @@ impl CurseForgePlatform { } } - pub const fn with_client(client: Arc, api_key: Option) -> Self { + pub const fn with_client( + client: Arc, + api_key: Option, + ) -> Self { Self { client, api_key } } @@ -57,7 +60,6 @@ impl CurseForgePlatform { const fn map_class_id(class_id: u32) -> ProjectType { match class_id { - 6 => ProjectType::Mod, 12 => ProjectType::ResourcePack, 6945 => ProjectType::DataPack, 6552 => ProjectType::Shader, @@ -68,7 +70,6 @@ impl CurseForgePlatform { const fn map_release_type(release_type: u32) -> ReleaseType { match release_type { - 1 => ReleaseType::Release, 2 => ReleaseType::Beta, 3 => ReleaseType::Alpha, _ => ReleaseType::Release, @@ -142,7 +143,7 @@ impl CurseForgePlatform { } } - fn convert_project(&self, cf_project: CurseForgeProject) -> Project { + fn convert_project(cf_project: CurseForgeProject) -> Project { let pakku_id = generate_pakku_id(); let project_type = Self::map_class_id(cf_project.class_id.unwrap_or(6)); @@ -162,11 +163,7 @@ impl CurseForgePlatform { project } - fn convert_file( - &self, - cf_file: CurseForgeFile, - project_id: &str, - ) -> ProjectFile { + fn convert_file(cf_file: CurseForgeFile, project_id: &str) -> ProjectFile { let mut hashes = HashMap::new(); for hash in cf_file.hashes { @@ -259,12 +256,12 @@ impl PlatformClient for CurseForgePlatform { if response.status().is_success() { let result: CurseForgeProjectResponse = response.json().await?; - return Ok(self.convert_project(result.data)); + return Ok(Self::convert_project(result.data)); } } let cf_project = self.search_project_by_slug(identifier).await?; - Ok(self.convert_project(cf_project)) + Ok(Self::convert_project(cf_project)) } async fn request_project_files( @@ -319,7 +316,7 @@ impl PlatformClient for CurseForgePlatform { let files: Vec = result .data .into_iter() - .map(|f| self.convert_file(f, project_id)) + .map(|f| Self::convert_file(f, project_id)) .collect(); Ok(files) @@ -398,7 +395,7 @@ impl PlatformClient for CurseForgePlatform { ) -> Result> { // Try to fetch project by slug using search API match self.search_project_by_slug(slug).await { - Ok(cf_project) => Ok(Some(self.convert_project(cf_project))), + Ok(cf_project) => Ok(Some(Self::convert_project(cf_project))), Err(PakkerError::ProjectNotFound(_)) => Ok(None), Err(e) => Err(e), } @@ -411,6 +408,11 @@ impl PlatformClient for CurseForgePlatform { hashes: &[String], _algorithm: &str, ) -> Result> { + #[derive(Serialize)] + struct FingerprintRequest { + fingerprints: Vec, + } + if hashes.is_empty() { return Ok(Vec::new()); } @@ -424,11 +426,6 @@ impl PlatformClient for CurseForgePlatform { return Ok(Vec::new()); } - #[derive(Serialize)] - struct FingerprintRequest { - fingerprints: Vec, - } - let url = format!("{CURSEFORGE_API_BASE}/fingerprints/432"); let response = self .client diff --git a/src/platform/github.rs b/src/platform/github.rs index 802e3c7..7df7d75 100644 --- a/src/platform/github.rs +++ b/src/platform/github.rs @@ -1,4 +1,7 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::HashMap, + sync::{Arc, LazyLock}, +}; use async_trait::async_trait; use regex::Regex; @@ -20,9 +23,9 @@ pub struct GitHubPlatform { } impl GitHubPlatform { - pub fn with_client(client: Arc, token: Option) -> Self { + pub fn with_client(client: &Arc, token: Option) -> Self { Self { - client: (*client).clone(), + client: (**client).clone(), token, } } @@ -70,7 +73,6 @@ impl GitHubPlatform { } fn convert_release( - &self, owner: &str, repo: &str, release: GitHubRelease, @@ -91,9 +93,15 @@ impl GitHubPlatform { } } +#[expect(clippy::expect_used, reason = "regex literal is always valid")] +static MC_VERSION_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?:^|[^\d.])(\d+\.\d+(?:\.\d+)?)(?:[^\d]|$)") + .expect("MC_VERSION_RE pattern is valid") +}); + // Helper functions for extracting metadata from GitHub releases fn extract_mc_versions(tag: &str, asset_name: &str) -> Vec { - let re = Regex::new(r"(?:^|[^\d.])(\d+\.\d+(?:\.\d+)?)(?:[^\d]|$)").unwrap(); + let re = &*MC_VERSION_RE; let mut versions = Vec::new(); log::debug!("Extracting MC versions from tag='{tag}', asset='{asset_name}'"); @@ -182,8 +190,7 @@ fn detect_project_type(asset_name: &str, repo_name: &str) -> ProjectType { impl GitHubPlatform { fn convert_asset( - &self, - asset: GitHubAsset, + asset: &GitHubAsset, release: &GitHubRelease, repo_id: &str, repo_name: &str, @@ -278,7 +285,7 @@ impl PlatformClient for GitHubPlatform { ) -> Result { let (owner, repo) = Self::parse_repo_identifier(identifier)?; let release = self.get_latest_release(&owner, &repo).await?; - Ok(self.convert_release(&owner, &repo, release)) + Ok(Self::convert_release(&owner, &repo, release)) } async fn request_project_files( @@ -295,9 +302,14 @@ impl PlatformClient for GitHubPlatform { for release in releases { for asset in &release.assets { // Filter for .jar files (mods) or .zip files (modpacks) - if asset.name.ends_with(".jar") || asset.name.ends_with(".zip") { - let file = - self.convert_asset(asset.clone(), &release, project_id, &repo); + if std::path::Path::new(&asset.name) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("jar")) + || std::path::Path::new(&asset.name) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("zip")) + { + let file = Self::convert_asset(asset, &release, project_id, &repo); files.push(file); } } diff --git a/src/platform/modrinth.rs b/src/platform/modrinth.rs index 87eed5a..d05330d 100644 --- a/src/platform/modrinth.rs +++ b/src/platform/modrinth.rs @@ -34,7 +34,7 @@ impl ModrinthPlatform { return Err(PakkerError::ProjectNotFound(url.to_string())); } let mr_project: ModrinthProject = response.json().await?; - Ok(self.convert_project(mr_project)) + Ok(Self::convert_project(mr_project)) } async fn request_project_files_url( @@ -57,8 +57,8 @@ impl ModrinthPlatform { .to_string(); Ok( mr_versions - .into_iter() - .map(|v| self.convert_version(v, &project_id)) + .iter() + .map(|v| Self::convert_version(v, &project_id)) .collect(), ) } @@ -86,7 +86,6 @@ impl ModrinthPlatform { fn map_project_type(type_str: &str) -> ProjectType { match type_str { - "mod" => ProjectType::Mod, "resourcepack" => ProjectType::ResourcePack, "datapack" => ProjectType::DataPack, "shader" => ProjectType::Shader, @@ -96,7 +95,6 @@ impl ModrinthPlatform { const fn map_side(client: bool, server: bool) -> ProjectSide { match (client, server) { - (true, true) => ProjectSide::Both, (true, false) => ProjectSide::Client, (false, true) => ProjectSide::Server, _ => ProjectSide::Both, @@ -105,14 +103,13 @@ impl ModrinthPlatform { fn map_release_type(type_str: &str) -> ReleaseType { match type_str { - "release" => ReleaseType::Release, "beta" => ReleaseType::Beta, "alpha" => ReleaseType::Alpha, _ => ReleaseType::Release, } } - fn convert_project(&self, mr_project: ModrinthProject) -> Project { + fn convert_project(mr_project: ModrinthProject) -> Project { let pakku_id = generate_pakku_id(); let mut project = Project::new( pakku_id, @@ -133,9 +130,12 @@ impl ModrinthPlatform { project } + #[expect( + clippy::expect_used, + reason = "Modrinth API guarantees every version has at least one file" + )] fn convert_version( - &self, - mr_version: ModrinthVersion, + mr_version: &ModrinthVersion, project_id: &str, ) -> ProjectFile { let mut hashes = HashMap::new(); @@ -274,7 +274,7 @@ impl PlatformClient for ModrinthPlatform { } let mr_project: ModrinthProject = response.json().await?; - Ok(Some(self.convert_project(mr_project))) + Ok(Some(Self::convert_project(mr_project))) } /// Uses Modrinth's `/v2/version_files` endpoint to resolve projects by @@ -284,10 +284,6 @@ impl PlatformClient for ModrinthPlatform { hashes: &[String], algorithm: &str, ) -> Result> { - if hashes.is_empty() { - return Ok(Vec::new()); - } - #[derive(Serialize)] struct HashBatchRequest<'a> { hashes: &'a [String], @@ -299,6 +295,10 @@ impl PlatformClient for ModrinthPlatform { project_id: String, } + if hashes.is_empty() { + return Ok(Vec::new()); + } + let url = format!("{MODRINTH_API_BASE}/version_files"); let response = self .client @@ -326,12 +326,11 @@ impl PlatformClient for ModrinthPlatform { } seen_project_ids.insert(version.project_id.clone()); - match self + if let Ok(project) = self .request_project_with_files(&version.project_id, &[], &[]) .await { - Ok(project) => projects.push(project), - Err(_) => continue, + projects.push(project); } } diff --git a/src/resolver.rs b/src/resolver.rs index 906d1b5..1335b44 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -100,6 +100,10 @@ impl DependencyResolver { }) } + #[expect( + clippy::expect_used, + reason = "projects.len() == 1 is checked directly above" + )] async fn fetch_dependency( &self, dep_id: &str, @@ -132,7 +136,7 @@ impl DependencyResolver { } if projects.len() == 1 { - Ok(projects.into_iter().next().unwrap()) + Ok(projects.into_iter().next().expect("length is exactly 1")) } else { let mut merged = projects.remove(0); for project in projects { diff --git a/src/ui_utils.rs b/src/ui_utils.rs index f293256..92647d8 100644 --- a/src/ui_utils.rs +++ b/src/ui_utils.rs @@ -166,12 +166,12 @@ pub fn prompt_input_optional(prompt: &str) -> io::Result> { pub fn prompt_curseforge_api_key( skip_prompts: bool, ) -> io::Result> { + use dialoguer::Password; + if skip_prompts { return Ok(None); } - use dialoguer::Password; - println!(); println!("CurseForge API key is required but not configured."); println!("Get your API key from: https://console.curseforge.com/"); diff --git a/src/utils/flexver.rs b/src/utils/flexver.rs index 1d562d3..1b15bf6 100644 --- a/src/utils/flexver.rs +++ b/src/utils/flexver.rs @@ -45,35 +45,14 @@ fn is_semver_prerelease(s: &str) -> bool { /// Decompose a version string into its component parts fn decompose(str_in: &str) -> VecDeque { - if str_in.is_empty() { - return VecDeque::new(); - } - - // Strip build metadata (after `+`) - let s = if let Some((left, _)) = str_in.split_once('+') { - left - } else { - str_in - }; - - let mut out: VecDeque = VecDeque::new(); - let mut current = String::new(); - - let mut currently_numeric = s.starts_with(|c: char| c.is_ascii_digit()); - let mut skip = s.starts_with('-'); + use SortingType::{Lexical, Numerical, SemverPrerelease}; fn handle_split( current: &str, c: Option<&char>, currently_numeric: bool, ) -> Option { - let numeric = if let Some(c) = c { - c.is_ascii_digit() - } else { - false - }; - - use SortingType::{Lexical, Numerical, SemverPrerelease}; + let numeric = c.is_some_and(char::is_ascii_digit); if currently_numeric { if numeric { @@ -101,6 +80,23 @@ fn decompose(str_in: &str) -> VecDeque { } } + if str_in.is_empty() { + return VecDeque::new(); + } + + // Strip build metadata (after `+`) + let s = if let Some((left, _)) = str_in.split_once('+') { + left + } else { + str_in + }; + + let mut out: VecDeque = VecDeque::new(); + let mut current = String::new(); + + let mut currently_numeric = s.starts_with(|c: char| c.is_ascii_digit()); + let mut skip = s.starts_with('-'); + for c in s.chars() { if let Some(part) = handle_split(¤t, Some(&c), currently_numeric) { if skip { @@ -131,6 +127,10 @@ fn decompose(str_in: &str) -> VecDeque { /// This matches the behavior of flexver-java: /// - "1.0.0" > "1.0.0-beta" (release > pre-release) /// - "1.0.0-beta" < "1.0.0+build123" (pre-release < build metadata) +#[expect( + clippy::unreachable, + reason = "the VersionComparisonIterator never yields (None, None)" +)] pub fn compare(left: &str, right: &str) -> Ordering { let iter = VersionComparisonIterator { left: decompose(left), diff --git a/src/utils/hash.rs b/src/utils/hash.rs index c4f8370..7462254 100644 --- a/src/utils/hash.rs +++ b/src/utils/hash.rs @@ -15,7 +15,7 @@ pub fn hash_to_hex(hash: impl AsRef<[u8]>) -> String { let bytes = hash.as_ref(); let mut hex = String::with_capacity(bytes.len() * 2); for byte in bytes { - write!(hex, "{byte:02x}").unwrap(); + let _ = write!(hex, "{byte:02x}"); } hex } @@ -99,7 +99,7 @@ pub fn compute_md5>(path: P) -> Result { let hash = hasher.finalize(); let mut hex = String::with_capacity(hash.len() * 2); for byte in hash { - std::fmt::write(&mut hex, format_args!("{byte:02x}")).unwrap(); + let _ = std::fmt::write(&mut hex, format_args!("{byte:02x}")); } Ok(hex) } From a642b976e9297ea894428a1b4460b092c6caada0 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 21 Apr 2026 19:26:33 +0300 Subject: [PATCH 18/18] chore: add missing manifest fields to Cargo manifest Signed-off-by: NotAShelf Change-Id: I31ce255cf7241f61600c0384bb703f966a6a6964 --- Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 7b74d38..04b3b9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,9 @@ name = "pakker" version = "0.1.0" edition = "2024" authors = [ "NotAShelf " ] +description = "A fast, reliable multiplatform modpack manager for Minecraft" +keywords = [ "minecraft", "modpack", "modrinth", "curseforge", "package-manager" ] +categories = [ "command-line-utilities", "games" ] rust-version = "1.94.0" readme = true