various: resolve multi-platform lookup; improve error messages

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Iec6ee73639d0b42c96127db657575ab86a6a6964
This commit is contained in:
raf 2026-04-21 23:35:09 +03:00
commit 1079635cb9
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
5 changed files with 232 additions and 25 deletions

View file

@ -35,8 +35,10 @@ async fn resolve_input(
platforms: &HashMap<String, Box<dyn crate::platform::PlatformClient>>, platforms: &HashMap<String, Box<dyn crate::platform::PlatformClient>>,
lockfile: &LockFile, lockfile: &LockFile,
) -> Result<Project> { ) -> Result<Project> {
for platform in platforms.values() { let mut projects = Vec::new();
if let Ok(project) = platform
for (platform_name, client) in platforms {
match client
.request_project_with_files( .request_project_with_files(
input, input,
&lockfile.mc_versions, &lockfile.mc_versions,
@ -44,11 +46,29 @@ async fn resolve_input(
) )
.await .await
{ {
return Ok(project); Ok(project) => {
log::debug!("Resolved '{input}' on {platform_name}");
projects.push(project);
},
Err(e) => {
log::debug!("Could not resolve '{input}' on {platform_name}: {e}");
},
} }
} }
Err(PakkerError::ProjectNotFound(input.to_string())) if projects.is_empty() {
return Err(PakkerError::ProjectNotFound(input.to_string()));
}
if projects.len() == 1 {
return Ok(projects.remove(0));
}
let mut merged = projects.remove(0);
for project in projects {
merged.merge(project);
}
Ok(merged)
} }
use std::path::Path; use std::path::Path;
@ -111,16 +131,24 @@ pub async fn execute(
} }
// Load parent lockfile to get metadata // Load parent lockfile to get metadata
let parent_lockfile = parent_paths let parent_lock_path = parent_paths
.iter() .iter()
.find(|path| path.exists()) .find(|path| path.exists())
.and_then(|path| LockFile::load(path.parent()?).ok())
.ok_or_else(|| { .ok_or_else(|| {
PakkerError::IoError(std::io::Error::new( PakkerError::IoError(std::io::Error::new(
std::io::ErrorKind::NotFound, std::io::ErrorKind::NotFound,
"Failed to load parent lockfile metadata", "Parent lockfile not found at expected paths",
)) ))
})?; })?;
let parent_lockfile = LockFile::load_with_validation(
parent_lock_path.parent().ok_or_else(|| {
PakkerError::IoError(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Parent lockfile path has no parent directory",
))
})?,
false,
)?;
let minimal_lockfile = LockFile { let minimal_lockfile = LockFile {
target: parent_lockfile.target, target: parent_lockfile.target,

View file

@ -702,7 +702,11 @@ fn execute_promote(projects: &[String]) -> Result<(), PakkerError> {
})?; })?;
// Load or create local lockfile // Load or create local lockfile
let lockfile_path = config_dir.join("pakku-lock.json"); let lockfile_path = if config_dir.join("pakker-lock.json").exists() {
config_dir.join("pakker-lock.json")
} else {
config_dir.join("pakku-lock.json")
};
let mut local_lockfile = if lockfile_path.exists() { let mut local_lockfile = if lockfile_path.exists() {
LockFile::load_with_validation(config_dir, false).map_err(|e| { LockFile::load_with_validation(config_dir, false).map_err(|e| {
PakkerError::Fork(format!("Failed to load local lockfile: {e}")) PakkerError::Fork(format!("Failed to load local lockfile: {e}"))

View file

@ -120,7 +120,7 @@ impl IpcCoordinator {
pakku_path pakku_path
} else { } else {
return Err(IpcError::PakkuJsonReadFailed( return Err(IpcError::PakkuJsonReadFailed(
"pakku.json not found in working directory".to_string(), "pakker.json or pakku.json not found in working directory".to_string(),
)); ));
}; };

View file

@ -533,10 +533,7 @@ mod tests {
for (tag, asset, expected) in cases { for (tag, asset, expected) in cases {
let result = extract_mc_versions(tag, asset); let result = extract_mc_versions(tag, asset);
assert_eq!( assert_eq!(result, expected, "Failed for tag: {tag}, asset: {asset}");
result, expected,
"Failed for tag: {tag}, asset: {asset}"
);
} }
} }
@ -567,10 +564,7 @@ mod tests {
for (tag, asset, expected) in cases { for (tag, asset, expected) in cases {
let result = extract_loaders(tag, asset); let result = extract_loaders(tag, asset);
assert_eq!( assert_eq!(result, expected, "Failed for tag: {tag}, asset: {asset}");
result, expected,
"Failed for tag: {tag}, asset: {asset}"
);
} }
} }

View file

@ -78,21 +78,22 @@ impl PlatformClient for MultiplatformPlatform {
let mut cf_project = cf_project; let mut cf_project = cf_project;
let mut mr_project = mr_project; let mut mr_project = mr_project;
let mr_found_and_cf_missing = mr_project.is_some() && cf_project.is_none(); // Cross-reference using each platform's own slug on the other platform.
if mr_found_and_cf_missing // Modrinth projects store their slug under "modrinth"; CurseForge under
// "curseforge". Many mods share the same slug across platforms.
if cf_project.is_none()
&& let Some(ref mr) = mr_project && let Some(ref mr) = mr_project
&& let Some(cf_slug) = mr.slug.get("curseforge") && let Some(mr_slug) = mr.slug.get("modrinth")
&& let Ok(Some(cf)) = && let Ok(Some(cf)) =
self.curseforge.request_project_from_slug(cf_slug).await self.curseforge.request_project_from_slug(mr_slug).await
{ {
cf_project = Some(cf); cf_project = Some(cf);
} }
let cf_found_and_mr_missing = cf_project.is_some() && mr_project.is_none(); if mr_project.is_none()
if cf_found_and_mr_missing
&& let Some(ref cf) = cf_project && let Some(ref cf) = cf_project
&& let Some(mr_slug) = cf.slug.get("modrinth") && let Some(cf_slug) = cf.slug.get("curseforge")
&& let Ok(Some(mr)) = && let Ok(Some(mr)) =
self.modrinth.request_project_from_slug(mr_slug).await self.modrinth.request_project_from_slug(cf_slug).await
{ {
mr_project = Some(mr); mr_project = Some(mr);
} }
@ -232,3 +233,183 @@ impl PlatformClient for MultiplatformPlatform {
Ok(all_projects) Ok(all_projects)
} }
} }
#[cfg(test)]
mod tests {
use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use super::*;
use crate::{
error::{PakkerError, Result},
model::{Project, ProjectFile, ProjectSide, ProjectType},
};
struct MockPlatform {
projects: HashMap<String, Project>,
slug_map: HashMap<String, Project>,
}
impl MockPlatform {
fn new() -> Self {
Self {
projects: HashMap::new(),
slug_map: HashMap::new(),
}
}
fn with_project(mut self, id: &str, project: Project) -> Self {
self.projects.insert(id.to_string(), project);
self
}
fn with_slug(mut self, slug: &str, project: Project) -> Self {
self.slug_map.insert(slug.to_string(), project);
self
}
}
#[async_trait]
impl PlatformClient for MockPlatform {
async fn request_project(
&self,
project_id: &str,
_mc_versions: &[String],
_loaders: &[String],
) -> Result<Project> {
self
.projects
.get(project_id)
.cloned()
.ok_or_else(|| PakkerError::ProjectNotFound(project_id.to_string()))
}
async fn request_project_files(
&self,
_project_id: &str,
_mc_versions: &[String],
_loaders: &[String],
) -> Result<Vec<ProjectFile>> {
Ok(vec![])
}
async fn request_project_with_files(
&self,
project_id: &str,
mc_versions: &[String],
loaders: &[String],
) -> Result<Project> {
self.request_project(project_id, mc_versions, loaders).await
}
async fn lookup_by_hash(&self, _hash: &str) -> Result<Option<Project>> {
Ok(None)
}
async fn request_project_from_slug(
&self,
slug: &str,
) -> Result<Option<Project>> {
Ok(self.slug_map.get(slug).cloned())
}
async fn request_projects_from_hashes(
&self,
_hashes: &[String],
_algorithm: &str,
) -> Result<Vec<Project>> {
Ok(vec![])
}
}
fn make_project(platform: &str, id: &str, slug: &str) -> Project {
let mut project =
Project::new(slug.to_string(), ProjectType::Mod, ProjectSide::Both);
project.id.insert(platform.to_string(), id.to_string());
project.slug.insert(platform.to_string(), slug.to_string());
project.name.insert(platform.to_string(), slug.to_string());
project
}
#[tokio::test]
async fn test_cross_reference_modrinth_to_curseforge() {
let mr_project = make_project("modrinth", "mr-abc", "sodium");
let cf_project = make_project("curseforge", "12345", "sodium");
let modrinth =
Arc::new(MockPlatform::new().with_project("sodium", mr_project.clone()));
let curseforge =
Arc::new(MockPlatform::new().with_slug("sodium", cf_project.clone()));
let platform = MultiplatformPlatform::new(curseforge, modrinth);
let result = platform.request_project("sodium", &[], &[]).await.unwrap();
assert_eq!(result.id.get("modrinth"), Some(&"mr-abc".to_string()));
assert_eq!(result.id.get("curseforge"), Some(&"12345".to_string()));
}
#[tokio::test]
async fn test_cross_reference_curseforge_to_modrinth() {
let cf_project = make_project("curseforge", "12345", "sodium");
let mr_project = make_project("modrinth", "mr-abc", "sodium");
let modrinth =
Arc::new(MockPlatform::new().with_slug("sodium", mr_project.clone()));
let curseforge =
Arc::new(MockPlatform::new().with_project("sodium", cf_project.clone()));
let platform = MultiplatformPlatform::new(curseforge, modrinth);
let result = platform.request_project("sodium", &[], &[]).await.unwrap();
assert_eq!(result.id.get("curseforge"), Some(&"12345".to_string()));
assert_eq!(result.id.get("modrinth"), Some(&"mr-abc".to_string()));
}
#[tokio::test]
async fn test_found_on_both_platforms_merged() {
let cf_project = make_project("curseforge", "12345", "sodium");
let mr_project = make_project("modrinth", "mr-abc", "sodium");
let modrinth =
Arc::new(MockPlatform::new().with_project("sodium", mr_project));
let curseforge =
Arc::new(MockPlatform::new().with_project("sodium", cf_project));
let platform = MultiplatformPlatform::new(curseforge, modrinth);
let result = platform.request_project("sodium", &[], &[]).await.unwrap();
assert_eq!(result.id.get("curseforge"), Some(&"12345".to_string()));
assert_eq!(result.id.get("modrinth"), Some(&"mr-abc".to_string()));
}
#[tokio::test]
async fn test_not_found_on_either_platform() {
let modrinth = Arc::new(MockPlatform::new());
let curseforge = Arc::new(MockPlatform::new());
let platform = MultiplatformPlatform::new(curseforge, modrinth);
let result = platform.request_project("nonexistent", &[], &[]).await;
assert!(matches!(result, Err(PakkerError::ProjectNotFound(_))));
}
#[tokio::test]
async fn test_no_cross_reference_when_slug_absent() {
// CurseForge returns a project, but slug lookup on Modrinth finds nothing
let cf_project = make_project("curseforge", "12345", "rare-mod");
let modrinth = Arc::new(MockPlatform::new());
let curseforge =
Arc::new(MockPlatform::new().with_project("rare-mod", cf_project));
let platform = MultiplatformPlatform::new(curseforge, modrinth);
let result = platform
.request_project("rare-mod", &[], &[])
.await
.unwrap();
assert_eq!(result.id.get("curseforge"), Some(&"12345".to_string()));
assert!(result.id.get("modrinth").is_none());
}
}