initial commit
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ife1391ed23a1e7f388b1b5eca90b9ea76a6a6964
This commit is contained in:
commit
ef28bdaeb4
63 changed files with 17292 additions and 0 deletions
383
src/platform/curseforge.rs
Normal file
383
src/platform/curseforge.rs
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::traits::PlatformClient;
|
||||
use crate::{
|
||||
error::{PakkerError, Result},
|
||||
model::{Project, ProjectFile, ProjectSide, ProjectType, ReleaseType},
|
||||
utils::generate_pakku_id,
|
||||
};
|
||||
|
||||
const CURSEFORGE_API_BASE: &str = "https://api.curseforge.com/v1";
|
||||
const LOADER_VERSION_TYPE_ID: i32 = 68441;
|
||||
|
||||
pub struct CurseForgePlatform {
|
||||
client: Client,
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl CurseForgePlatform {
|
||||
pub fn new(api_key: Option<String>) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_key,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_headers(&self) -> Result<reqwest::header::HeaderMap> {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
|
||||
if let Some(api_key) = &self.api_key {
|
||||
headers.insert(
|
||||
"x-api-key",
|
||||
reqwest::header::HeaderValue::from_str(api_key).map_err(|_| {
|
||||
PakkerError::ConfigError("Invalid API key".to_string())
|
||||
})?,
|
||||
);
|
||||
} else {
|
||||
return Err(PakkerError::ConfigError(
|
||||
"CurseForge API key required".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
const fn map_class_id(class_id: u32) -> ProjectType {
|
||||
match class_id {
|
||||
6 => ProjectType::Mod,
|
||||
12 => ProjectType::ResourcePack,
|
||||
6945 => ProjectType::DataPack,
|
||||
6552 => ProjectType::Shader,
|
||||
17 => ProjectType::World,
|
||||
_ => ProjectType::Mod,
|
||||
}
|
||||
}
|
||||
|
||||
const fn map_release_type(release_type: u32) -> ReleaseType {
|
||||
match release_type {
|
||||
1 => ReleaseType::Release,
|
||||
2 => ReleaseType::Beta,
|
||||
3 => ReleaseType::Alpha,
|
||||
_ => ReleaseType::Release,
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_project(&self, cf_project: CurseForgeProject) -> Project {
|
||||
let pakku_id = generate_pakku_id();
|
||||
let project_type = Self::map_class_id(cf_project.class_id.unwrap_or(6));
|
||||
|
||||
let mut project = Project::new(pakku_id, project_type, ProjectSide::Both);
|
||||
|
||||
project.add_platform(
|
||||
"curseforge".to_string(),
|
||||
cf_project.id.to_string(),
|
||||
cf_project.slug.clone(),
|
||||
cf_project.name,
|
||||
);
|
||||
|
||||
project.redistributable = false;
|
||||
project
|
||||
}
|
||||
|
||||
fn convert_file(
|
||||
&self,
|
||||
cf_file: CurseForgeFile,
|
||||
project_id: &str,
|
||||
) -> ProjectFile {
|
||||
let mut hashes = HashMap::new();
|
||||
|
||||
for hash in cf_file.hashes {
|
||||
hashes.insert(hash.algo.to_lowercase(), hash.value.clone());
|
||||
}
|
||||
|
||||
let mc_versions: Vec<String> = cf_file.game_versions.clone();
|
||||
|
||||
// Extract loaders from sortableGameVersions with LOADER_VERSION_TYPE_ID
|
||||
let loaders: Vec<String> = cf_file
|
||||
.sortable_game_versions
|
||||
.iter()
|
||||
.filter(|v| v.game_version_type_id == Some(LOADER_VERSION_TYPE_ID))
|
||||
.map(|v| v.game_version_name.to_lowercase())
|
||||
.collect();
|
||||
|
||||
ProjectFile {
|
||||
file_type: "mod".to_string(),
|
||||
file_name: cf_file.file_name.clone(),
|
||||
mc_versions,
|
||||
loaders,
|
||||
release_type: Self::map_release_type(cf_file.release_type.unwrap_or(1)),
|
||||
url: cf_file.download_url.clone().unwrap_or_else(|| {
|
||||
format!(
|
||||
"https://edge.forgecdn.net/files/{}/{}/{}",
|
||||
cf_file.id / 1000,
|
||||
cf_file.id % 1000,
|
||||
cf_file.file_name
|
||||
)
|
||||
}),
|
||||
id: cf_file.id.to_string(),
|
||||
parent_id: project_id.to_string(),
|
||||
hashes,
|
||||
required_dependencies: cf_file
|
||||
.dependencies
|
||||
.iter()
|
||||
.filter(|d| d.relation_type == 3)
|
||||
.map(|d| d.mod_id.to_string())
|
||||
.collect(),
|
||||
size: cf_file.file_length,
|
||||
date_published: cf_file.file_date.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn search_project_by_slug(
|
||||
&self,
|
||||
slug: &str,
|
||||
) -> Result<CurseForgeProject> {
|
||||
let url =
|
||||
format!("{CURSEFORGE_API_BASE}/mods/search?gameId=432&slug={slug}");
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.headers(self.get_headers()?)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PakkerError::ProjectNotFound(slug.to_string()));
|
||||
}
|
||||
|
||||
let result: CurseForgeSearchResponse = response.json().await?;
|
||||
|
||||
result
|
||||
.data
|
||||
.into_iter()
|
||||
.find(|p| p.slug == slug)
|
||||
.ok_or_else(|| PakkerError::ProjectNotFound(slug.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PlatformClient for CurseForgePlatform {
|
||||
async fn request_project(
|
||||
&self,
|
||||
identifier: &str,
|
||||
_mc_versions: &[String],
|
||||
_loaders: &[String],
|
||||
) -> Result<Project> {
|
||||
if let Ok(mod_id) = identifier.parse::<u32>() {
|
||||
let url = format!("{CURSEFORGE_API_BASE}/mods/{mod_id}");
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.headers(self.get_headers()?)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let result: CurseForgeProjectResponse = response.json().await?;
|
||||
return Ok(self.convert_project(result.data));
|
||||
}
|
||||
}
|
||||
|
||||
let cf_project = self.search_project_by_slug(identifier).await?;
|
||||
Ok(self.convert_project(cf_project))
|
||||
}
|
||||
|
||||
async fn request_project_files(
|
||||
&self,
|
||||
project_id: &str,
|
||||
mc_versions: &[String],
|
||||
loaders: &[String],
|
||||
) -> Result<Vec<ProjectFile>> {
|
||||
let mut url = format!("{CURSEFORGE_API_BASE}/mods/{project_id}/files");
|
||||
|
||||
// Add query parameters for server-side filtering (Pakku-compatible)
|
||||
let mut query_params = Vec::new();
|
||||
|
||||
// Add gameVersionTypeId for each MC version (requires lookup)
|
||||
if !mc_versions.is_empty() {
|
||||
// Fetch game version type IDs
|
||||
// Add MC version gameVersionTypeId = 73250 for Minecraft versions
|
||||
for mc_version in mc_versions {
|
||||
query_params.push(("gameVersion", mc_version.clone()));
|
||||
}
|
||||
query_params.push(("gameVersionTypeId", "73250".to_string()));
|
||||
}
|
||||
|
||||
// Add mod loader types
|
||||
if !loaders.is_empty() {
|
||||
let loader_str = loaders.join(",");
|
||||
query_params.push(("modLoaderTypes", loader_str));
|
||||
}
|
||||
|
||||
if !query_params.is_empty() {
|
||||
let query_string = query_params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
url = format!("{url}?{query_string}");
|
||||
}
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.headers(self.get_headers()?)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PakkerError::ProjectNotFound(project_id.to_string()));
|
||||
}
|
||||
|
||||
let result: CurseForgeFilesResponse = response.json().await?;
|
||||
|
||||
let files: Vec<ProjectFile> = result
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|f| self.convert_file(f, project_id))
|
||||
.collect();
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
async fn request_project_with_files(
|
||||
&self,
|
||||
identifier: &str,
|
||||
mc_versions: &[String],
|
||||
loaders: &[String],
|
||||
) -> Result<Project> {
|
||||
let mut project = self
|
||||
.request_project(identifier, mc_versions, loaders)
|
||||
.await?;
|
||||
let project_id = project
|
||||
.get_platform_id("curseforge")
|
||||
.ok_or_else(|| {
|
||||
PakkerError::InternalError("Missing curseforge ID".to_string())
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let files = self
|
||||
.request_project_files(&project_id, mc_versions, loaders)
|
||||
.await?;
|
||||
project.files = files;
|
||||
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
async fn lookup_by_hash(&self, hash: &str) -> Result<Option<Project>> {
|
||||
// CurseForge uses Murmur2 hash for file fingerprints
|
||||
let fingerprint = hash
|
||||
.parse::<u32>()
|
||||
.map_err(|_| PakkerError::InvalidHash(hash.to_string()))?;
|
||||
|
||||
let url = format!("{CURSEFORGE_API_BASE}/fingerprints");
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.headers(self.get_headers()?)
|
||||
.json(&serde_json::json!({
|
||||
"fingerprints": [fingerprint]
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let response_data: serde_json::Value = response.json().await?;
|
||||
|
||||
if let Some(matches) = response_data["data"]["exactMatches"].as_array()
|
||||
&& let Some(first_match) = matches.first()
|
||||
&& let Some(file) = first_match["file"].as_object()
|
||||
{
|
||||
let mod_id = file["modId"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| {
|
||||
PakkerError::InvalidResponse("Missing modId".to_string())
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
return self
|
||||
.request_project_with_files(&mod_id, &[], &[])
|
||||
.await
|
||||
.map(Some);
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// CurseForge API models
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct CurseForgeProject {
|
||||
id: u32,
|
||||
name: String,
|
||||
slug: String,
|
||||
#[serde(rename = "classId")]
|
||||
class_id: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct CurseForgeGameVersion {
|
||||
#[serde(rename = "gameVersionName")]
|
||||
game_version_name: String,
|
||||
#[serde(rename = "gameVersionTypeId")]
|
||||
game_version_type_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct CurseForgeHash {
|
||||
algo: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct CurseForgeDependency {
|
||||
#[serde(rename = "modId")]
|
||||
mod_id: u32,
|
||||
#[serde(rename = "relationType")]
|
||||
relation_type: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct CurseForgeProjectResponse {
|
||||
data: CurseForgeProject,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct CurseForgeFile {
|
||||
id: u32,
|
||||
#[serde(rename = "fileName")]
|
||||
file_name: String,
|
||||
#[serde(rename = "downloadUrl")]
|
||||
download_url: Option<String>,
|
||||
#[serde(rename = "gameVersions")]
|
||||
game_versions: Vec<String>,
|
||||
#[serde(rename = "sortableGameVersions")]
|
||||
sortable_game_versions: Vec<CurseForgeGameVersion>,
|
||||
#[serde(rename = "releaseType")]
|
||||
release_type: Option<u32>,
|
||||
#[serde(rename = "fileLength")]
|
||||
file_length: u64,
|
||||
#[serde(rename = "fileDate")]
|
||||
file_date: String,
|
||||
hashes: Vec<CurseForgeHash>,
|
||||
dependencies: Vec<CurseForgeDependency>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct CurseForgeFilesResponse {
|
||||
data: Vec<CurseForgeFile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct CurseForgeSearchResponse {
|
||||
data: Vec<CurseForgeProject>,
|
||||
}
|
||||
580
src/platform/github.rs
Normal file
580
src/platform/github.rs
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::traits::PlatformClient;
|
||||
use crate::{
|
||||
error::{PakkerError, Result},
|
||||
model::{Project, ProjectFile, ProjectSide, ProjectType, ReleaseType},
|
||||
utils::generate_pakku_id,
|
||||
};
|
||||
|
||||
const GITHUB_API_BASE: &str = "https://api.github.com";
|
||||
|
||||
pub struct GitHubPlatform {
|
||||
client: Client,
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
impl GitHubPlatform {
|
||||
pub fn new(token: Option<String>) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
token,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_headers(&self) -> Result<reqwest::header::HeaderMap> {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::USER_AGENT,
|
||||
reqwest::header::HeaderValue::from_static("Pakker"),
|
||||
);
|
||||
|
||||
if let Some(token) = &self.token {
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
|
||||
.map_err(|_| {
|
||||
PakkerError::ConfigError("Invalid GitHub token".to_string())
|
||||
})?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn parse_repo_identifier(identifier: &str) -> Result<(String, String)> {
|
||||
// Expected formats:
|
||||
// - "owner/repo"
|
||||
// - "github:owner/repo"
|
||||
// - "https://github.com/owner/repo"
|
||||
|
||||
let identifier = identifier
|
||||
.trim_start_matches("github:")
|
||||
.trim_start_matches("https://github.com/")
|
||||
.trim_start_matches("http://github.com/")
|
||||
.trim_end_matches(".git");
|
||||
|
||||
let parts: Vec<&str> = identifier.split('/').collect();
|
||||
if parts.len() >= 2 {
|
||||
Ok((parts[0].to_string(), parts[1].to_string()))
|
||||
} else {
|
||||
Err(PakkerError::InvalidInput(format!(
|
||||
"Invalid GitHub repository identifier: {identifier}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_release(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
release: GitHubRelease,
|
||||
) -> Project {
|
||||
let pakku_id = generate_pakku_id();
|
||||
let mut project =
|
||||
Project::new(pakku_id, ProjectType::Mod, ProjectSide::Both);
|
||||
|
||||
let repo_full = format!("{owner}/{repo}");
|
||||
project.add_platform(
|
||||
"github".to_string(),
|
||||
repo_full.clone(),
|
||||
repo_full,
|
||||
release.name.unwrap_or_else(|| repo.to_string()),
|
||||
);
|
||||
|
||||
project
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for extracting metadata from GitHub releases
|
||||
fn extract_mc_versions(tag: &str, asset_name: &str) -> Vec<String> {
|
||||
let re = Regex::new(r"(?:^|[^\d.])(\d+\.\d+(?:\.\d+)?)(?:[^\d]|$)").unwrap();
|
||||
let mut versions = Vec::new();
|
||||
|
||||
log::debug!("Extracting MC versions from tag='{tag}', asset='{asset_name}'");
|
||||
|
||||
for text in &[tag, asset_name] {
|
||||
for cap in re.captures_iter(text) {
|
||||
if let Some(version) = cap.get(1) {
|
||||
let v = version.as_str().to_string();
|
||||
if !versions.contains(&v) {
|
||||
log::debug!(" Found MC version: {v}");
|
||||
versions.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!("Extracted MC versions: {versions:?}");
|
||||
versions
|
||||
}
|
||||
|
||||
fn extract_loaders(tag: &str, asset_name: &str) -> Vec<String> {
|
||||
let mut loaders = Vec::new();
|
||||
let text = format!("{} {}", tag.to_lowercase(), asset_name.to_lowercase());
|
||||
|
||||
log::debug!("Extracting loaders from: '{text}'");
|
||||
|
||||
if text.contains("fabric") {
|
||||
log::debug!(" Found loader: fabric");
|
||||
loaders.push("fabric".to_string());
|
||||
}
|
||||
if text.contains("forge") && !text.contains("neoforge") {
|
||||
log::debug!(" Found loader: forge");
|
||||
loaders.push("forge".to_string());
|
||||
}
|
||||
if text.contains("neoforge") {
|
||||
log::debug!(" Found loader: neoforge");
|
||||
loaders.push("neoforge".to_string());
|
||||
}
|
||||
if text.contains("quilt") {
|
||||
log::debug!(" Found loader: quilt");
|
||||
loaders.push("quilt".to_string());
|
||||
}
|
||||
|
||||
log::debug!("Extracted loaders: {loaders:?}");
|
||||
loaders
|
||||
}
|
||||
|
||||
fn detect_project_type(asset_name: &str, repo_name: &str) -> ProjectType {
|
||||
let name_lower = asset_name.to_lowercase();
|
||||
let repo_lower = repo_name.to_lowercase();
|
||||
|
||||
// Check for resourcepack indicators
|
||||
if name_lower.contains("resourcepack")
|
||||
|| name_lower.contains("resource-pack")
|
||||
|| name_lower.contains("texture")
|
||||
|| repo_lower.contains("resourcepack")
|
||||
|| repo_lower.contains("texture")
|
||||
{
|
||||
return ProjectType::ResourcePack;
|
||||
}
|
||||
|
||||
// Check for datapack indicators
|
||||
if name_lower.contains("datapack")
|
||||
|| name_lower.contains("data-pack")
|
||||
|| repo_lower.contains("datapack")
|
||||
{
|
||||
return ProjectType::DataPack;
|
||||
}
|
||||
|
||||
// Check for shader indicators
|
||||
if name_lower.contains("shader") || repo_lower.contains("shader") {
|
||||
return ProjectType::Shader;
|
||||
}
|
||||
|
||||
// Check for world/save indicators
|
||||
if name_lower.contains("world")
|
||||
|| name_lower.contains("save")
|
||||
|| repo_lower.contains("world")
|
||||
{
|
||||
return ProjectType::World;
|
||||
}
|
||||
|
||||
// Default to mod for .jar files
|
||||
ProjectType::Mod
|
||||
}
|
||||
|
||||
impl GitHubPlatform {
|
||||
fn convert_asset(
|
||||
&self,
|
||||
asset: GitHubAsset,
|
||||
release: &GitHubRelease,
|
||||
repo_id: &str,
|
||||
repo_name: &str,
|
||||
) -> ProjectFile {
|
||||
let hashes = HashMap::new();
|
||||
|
||||
// Extract MC versions and loaders from tag and asset name
|
||||
let mc_versions = extract_mc_versions(&release.tag_name, &asset.name);
|
||||
let loaders = extract_loaders(&release.tag_name, &asset.name);
|
||||
|
||||
// Detect project type from asset name and repo
|
||||
let file_type = match detect_project_type(&asset.name, repo_name) {
|
||||
ProjectType::Mod => "mod",
|
||||
ProjectType::ResourcePack => "resourcepack",
|
||||
ProjectType::DataPack => "datapack",
|
||||
ProjectType::Shader => "shader",
|
||||
ProjectType::World => "world",
|
||||
};
|
||||
|
||||
ProjectFile {
|
||||
file_type: file_type.to_string(),
|
||||
file_name: asset.name.clone(),
|
||||
mc_versions,
|
||||
loaders,
|
||||
release_type: if release.prerelease {
|
||||
ReleaseType::Beta
|
||||
} else {
|
||||
ReleaseType::Release
|
||||
},
|
||||
url: asset.browser_download_url.clone(),
|
||||
id: asset.id.to_string(),
|
||||
parent_id: repo_id.to_string(),
|
||||
hashes,
|
||||
required_dependencies: vec![],
|
||||
size: asset.size,
|
||||
date_published: release.published_at.clone().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_latest_release(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
) -> Result<GitHubRelease> {
|
||||
let url = format!("{GITHUB_API_BASE}/repos/{owner}/{repo}/releases/latest");
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.headers(self.get_headers()?)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PakkerError::ProjectNotFound(format!("{owner}/{repo}")));
|
||||
}
|
||||
|
||||
let release: GitHubRelease = response.json().await?;
|
||||
Ok(release)
|
||||
}
|
||||
|
||||
async fn get_all_releases(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
) -> Result<Vec<GitHubRelease>> {
|
||||
let url = format!("{GITHUB_API_BASE}/repos/{owner}/{repo}/releases");
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.headers(self.get_headers()?)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PakkerError::ProjectNotFound(format!("{owner}/{repo}")));
|
||||
}
|
||||
|
||||
let releases: Vec<GitHubRelease> = response.json().await?;
|
||||
Ok(releases)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PlatformClient for GitHubPlatform {
|
||||
async fn request_project(
|
||||
&self,
|
||||
identifier: &str,
|
||||
_mc_versions: &[String],
|
||||
_loaders: &[String],
|
||||
) -> Result<Project> {
|
||||
let (owner, repo) = Self::parse_repo_identifier(identifier)?;
|
||||
let release = self.get_latest_release(&owner, &repo).await?;
|
||||
Ok(self.convert_release(&owner, &repo, release))
|
||||
}
|
||||
|
||||
async fn request_project_files(
|
||||
&self,
|
||||
project_id: &str,
|
||||
_mc_versions: &[String],
|
||||
_loaders: &[String],
|
||||
) -> Result<Vec<ProjectFile>> {
|
||||
let (owner, repo) = Self::parse_repo_identifier(project_id)?;
|
||||
let releases = self.get_all_releases(&owner, &repo).await?;
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
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);
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
async fn request_project_with_files(
|
||||
&self,
|
||||
identifier: &str,
|
||||
_mc_versions: &[String],
|
||||
_loaders: &[String],
|
||||
) -> Result<Project> {
|
||||
let mut project = self
|
||||
.request_project(identifier, _mc_versions, _loaders)
|
||||
.await?;
|
||||
|
||||
let project_id = project
|
||||
.get_platform_id("github")
|
||||
.ok_or_else(|| {
|
||||
PakkerError::InternalError("Missing github ID".to_string())
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let files = self
|
||||
.request_project_files(&project_id, _mc_versions, _loaders)
|
||||
.await?;
|
||||
|
||||
project.files = files;
|
||||
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
async fn lookup_by_hash(&self, hash: &str) -> Result<Option<Project>> {
|
||||
log::debug!("GitHub lookup_by_hash: searching for hash={hash}");
|
||||
|
||||
// GitHub Code Search API: search for files containing the hash
|
||||
// Note: This is rate-limited (10 req/min without auth, 30 req/min with
|
||||
// auth)
|
||||
let url = format!("{GITHUB_API_BASE}/search/code?q={hash}+in:file");
|
||||
log::debug!("GitHub search URL: {url}");
|
||||
|
||||
let response = match self
|
||||
.client
|
||||
.get(&url)
|
||||
.headers(self.get_headers()?)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
log::debug!("GitHub search response status: {}", resp.status());
|
||||
resp
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!("GitHub hash lookup failed: {e}");
|
||||
return Ok(None);
|
||||
},
|
||||
};
|
||||
|
||||
// Handle rate limiting gracefully
|
||||
if response.status().as_u16() == 403 {
|
||||
log::warn!("GitHub API rate limit exceeded for hash lookup");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
log::debug!(
|
||||
"GitHub search returned non-success status: {}",
|
||||
response.status()
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let search_result: GitHubCodeSearchResult = match response.json().await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to parse GitHub search result: {e}");
|
||||
return Ok(None);
|
||||
},
|
||||
};
|
||||
|
||||
log::debug!("GitHub search found {} items", search_result.items.len());
|
||||
|
||||
// If we found matches, try to extract repo info from first result
|
||||
if let Some(item) = search_result.items.first() {
|
||||
let repo_full = item.repository.full_name.clone();
|
||||
log::info!("GitHub hash lookup found match in repo: {repo_full}");
|
||||
|
||||
// Try to get the latest release for this repo
|
||||
match self.request_project(&repo_full, &[], &[]).await {
|
||||
Ok(project) => {
|
||||
log::info!("GitHub hash lookup succeeded for {repo_full}");
|
||||
Ok(Some(project))
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!("Failed to fetch project for {repo_full}: {e}");
|
||||
Ok(None)
|
||||
},
|
||||
}
|
||||
} else {
|
||||
log::debug!("GitHub hash lookup found no matches");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub API models
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct GitHubRelease {
|
||||
id: u64,
|
||||
tag_name: String,
|
||||
name: Option<String>,
|
||||
prerelease: bool,
|
||||
published_at: Option<String>,
|
||||
assets: Vec<GitHubAsset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct GitHubAsset {
|
||||
id: u64,
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitHubCodeSearchResult {
|
||||
items: Vec<GitHubCodeSearchItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitHubCodeSearchItem {
|
||||
repository: GitHubRepository,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitHubRepository {
|
||||
full_name: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_repo_identifier() {
|
||||
let cases = vec![
|
||||
("owner/repo", ("owner", "repo")),
|
||||
("github:owner/repo", ("owner", "repo")),
|
||||
("https://github.com/owner/repo", ("owner", "repo")),
|
||||
("https://github.com/owner/repo.git", ("owner", "repo")),
|
||||
];
|
||||
|
||||
for (input, (expected_owner, expected_repo)) in cases {
|
||||
let (owner, repo) = GitHubPlatform::parse_repo_identifier(input).unwrap();
|
||||
assert_eq!(owner, expected_owner);
|
||||
assert_eq!(repo, expected_repo);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_repo_identifier_invalid() {
|
||||
let result = GitHubPlatform::parse_repo_identifier("invalid");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_mc_versions() {
|
||||
let cases = vec![
|
||||
("1.20.4-forge-1.0.0", "", vec!["1.20.4", "1.0.0"]),
|
||||
("fabric-1.21-1.0.0", "", vec!["1.21"]),
|
||||
("mc1.20.4", "", vec!["1.20.4"]),
|
||||
("1.20.1-1.20.2", "", vec!["1.20.1"]),
|
||||
("mymod-1.0.0", "", vec!["1.0.0"]),
|
||||
("mc1.20.4-v1.0.0", "", vec!["1.20.4", "1.0.0"]),
|
||||
("v1.0.0", "mymod-1.20.4.jar", vec!["1.0.0", "1.20.4"]),
|
||||
("1.20.1-47.1.0", "", vec!["1.20.1"]),
|
||||
("v0.5.1+1.20.1", "", vec!["0.5.1"]),
|
||||
("1.20.4-1.0.0+fabric", "", vec!["1.20.4"]),
|
||||
("mc1.19.2-v2.1.3", "", vec!["1.19.2", "2.1.3"]),
|
||||
("1.20-Snapshot", "", vec!["1.20"]),
|
||||
("v3.0.0-beta.2+mc1.20.4", "", vec!["3.0.0", "1.20.4"]),
|
||||
("1.16.5-1.0", "", vec!["1.16.5"]),
|
||||
("forge-1.20.1-47.2.0", "", vec!["1.20.1"]),
|
||||
("1.20.2-neoforge-20.2.59", "", vec!["1.20.2", "20.2.59"]),
|
||||
("release-1.20.1", "", vec!["1.20.1"]),
|
||||
("1.19.4_v2.5.0", "", vec!["1.19.4", "2.5.0"]),
|
||||
("MC1.18.2-v1.0.0", "", vec!["1.18.2", "1.0.0"]),
|
||||
("1.20.1-forge-v1.2.3", "", vec!["1.20.1", "1.2.3"]),
|
||||
("Minecraft_1.19.2-v0.8.1", "", vec!["1.19.2", "0.8.1"]),
|
||||
("build-1.20.4-2.1.0", "", vec!["1.20.4"]),
|
||||
("1.20.x-1.5.0", "", vec!["1.20", "1.5.0"]),
|
||||
("1.12.2-14.23.5.2859", "", vec!["1.12.2"]),
|
||||
];
|
||||
|
||||
for (tag, asset, expected) in cases {
|
||||
let result = extract_mc_versions(tag, asset);
|
||||
assert_eq!(
|
||||
result, expected,
|
||||
"Failed for tag: {}, asset: {}",
|
||||
tag, asset
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_loaders() {
|
||||
let cases = vec![
|
||||
("1.20.4-forge-1.0.0", "", vec!["forge"]),
|
||||
("fabric-1.21-1.0.0", "", vec!["fabric"]),
|
||||
("1.20.1-neoforge", "", vec!["neoforge"]),
|
||||
("quilt-1.20.4", "", vec!["quilt"]),
|
||||
("mymod-1.0.0", "", vec![]),
|
||||
("1.20.4-forge-fabric", "", vec!["fabric", "forge"]), /* Alphabetical
|
||||
* order */
|
||||
("v1.0.0", "mymod-fabric-1.20.4.jar", vec!["fabric"]),
|
||||
// Real-world patterns
|
||||
("1.20.1-forge-47.1.0", "", vec!["forge"]),
|
||||
("fabric-api-0.92.0+1.20.4", "", vec!["fabric"]),
|
||||
("1.19.2-neoforge-20.2.59", "", vec!["neoforge"]),
|
||||
("quilt-loader-0.23.0", "", vec!["quilt"]),
|
||||
("1.20.4-Fabric-1.0.0", "", vec!["fabric"]), // Capitalized
|
||||
("forge-1.20.1", "", vec!["forge"]),
|
||||
("v1.0.0-fabric", "", vec!["fabric"]),
|
||||
("1.18.2-forge+fabric", "", vec!["fabric", "forge"]), // Both loaders
|
||||
("NeoForge-1.20.2", "", vec!["neoforge"]), /* Capitalized
|
||||
* NeoForge */
|
||||
("1.12.2-forge-14.23.5.2859", "", vec!["forge"]), // Old format
|
||||
];
|
||||
|
||||
for (tag, asset, expected) in cases {
|
||||
let result = extract_loaders(tag, asset);
|
||||
assert_eq!(
|
||||
result, expected,
|
||||
"Failed for tag: {}, asset: {}",
|
||||
tag, asset
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_project_type() {
|
||||
let cases = vec![
|
||||
("mymod.jar", "mymod", crate::model::ProjectType::Mod),
|
||||
(
|
||||
"texture-pack.zip",
|
||||
"texture",
|
||||
crate::model::ProjectType::ResourcePack,
|
||||
),
|
||||
(
|
||||
"resourcepack.zip",
|
||||
"resources",
|
||||
crate::model::ProjectType::ResourcePack,
|
||||
),
|
||||
(
|
||||
"datapack.zip",
|
||||
"data-stuff",
|
||||
crate::model::ProjectType::DataPack,
|
||||
),
|
||||
(
|
||||
"shader.zip",
|
||||
"awesome-shaders",
|
||||
crate::model::ProjectType::Shader,
|
||||
),
|
||||
("world.zip", "my-world", crate::model::ProjectType::World),
|
||||
("save.zip", "survival", crate::model::ProjectType::World),
|
||||
("unknown.zip", "stuff", crate::model::ProjectType::Mod),
|
||||
];
|
||||
|
||||
for (filename, repo_name, expected) in cases {
|
||||
let result = detect_project_type(filename, repo_name);
|
||||
assert_eq!(
|
||||
result, expected,
|
||||
"Failed for filename: {}, repo: {}",
|
||||
filename, repo_name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
282
src/platform/modrinth.rs
Normal file
282
src/platform/modrinth.rs
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::traits::PlatformClient;
|
||||
use crate::{
|
||||
error::{PakkerError, Result},
|
||||
model::{Project, ProjectFile, ProjectSide, ProjectType, ReleaseType},
|
||||
utils::generate_pakku_id,
|
||||
};
|
||||
|
||||
const MODRINTH_API_BASE: &str = "https://api.modrinth.com/v2";
|
||||
|
||||
pub struct ModrinthPlatform {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl ModrinthPlatform {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_project_type(type_str: &str) -> ProjectType {
|
||||
match type_str {
|
||||
"mod" => ProjectType::Mod,
|
||||
"resourcepack" => ProjectType::ResourcePack,
|
||||
"datapack" => ProjectType::DataPack,
|
||||
"shader" => ProjectType::Shader,
|
||||
_ => ProjectType::Mod,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
let pakku_id = generate_pakku_id();
|
||||
let mut project = Project::new(
|
||||
pakku_id,
|
||||
Self::map_project_type(&mr_project.project_type),
|
||||
Self::map_side(
|
||||
mr_project.client_side != "unsupported",
|
||||
mr_project.server_side != "unsupported",
|
||||
),
|
||||
);
|
||||
|
||||
project.add_platform(
|
||||
"modrinth".to_string(),
|
||||
mr_project.id.clone(),
|
||||
mr_project.slug.clone(),
|
||||
mr_project.title,
|
||||
);
|
||||
|
||||
project
|
||||
}
|
||||
|
||||
fn convert_version(
|
||||
&self,
|
||||
mr_version: ModrinthVersion,
|
||||
project_id: &str,
|
||||
) -> ProjectFile {
|
||||
let mut hashes = HashMap::new();
|
||||
|
||||
// Get primary file
|
||||
let primary_file = mr_version
|
||||
.files
|
||||
.iter()
|
||||
.find(|f| f.primary)
|
||||
.or_else(|| mr_version.files.first())
|
||||
.expect("Version must have at least one file");
|
||||
|
||||
for (algo, hash) in &primary_file.hashes {
|
||||
hashes.insert(algo.clone(), hash.clone());
|
||||
}
|
||||
|
||||
ProjectFile {
|
||||
file_type: "mod".to_string(),
|
||||
file_name: primary_file.filename.clone(),
|
||||
mc_versions: mr_version.game_versions.clone(),
|
||||
loaders: mr_version.loaders.clone(),
|
||||
release_type: Self::map_release_type(&mr_version.version_type),
|
||||
url: primary_file.url.clone(),
|
||||
id: mr_version.id.clone(),
|
||||
parent_id: project_id.to_string(),
|
||||
hashes,
|
||||
required_dependencies: mr_version
|
||||
.dependencies
|
||||
.iter()
|
||||
.filter(|d| d.dependency_type == "required")
|
||||
.filter_map(|d| d.project_id.clone())
|
||||
.collect(),
|
||||
size: primary_file.size,
|
||||
date_published: mr_version.date_published.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PlatformClient for ModrinthPlatform {
|
||||
async fn request_project(
|
||||
&self,
|
||||
identifier: &str,
|
||||
_mc_versions: &[String],
|
||||
_loaders: &[String],
|
||||
) -> Result<Project> {
|
||||
let url = format!("{MODRINTH_API_BASE}/project/{identifier}");
|
||||
|
||||
let response = self.client.get(&url).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PakkerError::ProjectNotFound(identifier.to_string()));
|
||||
}
|
||||
|
||||
let mr_project: ModrinthProject = response.json().await?;
|
||||
Ok(self.convert_project(mr_project))
|
||||
}
|
||||
|
||||
async fn request_project_files(
|
||||
&self,
|
||||
project_id: &str,
|
||||
mc_versions: &[String],
|
||||
loaders: &[String],
|
||||
) -> Result<Vec<ProjectFile>> {
|
||||
let mut url = format!("{MODRINTH_API_BASE}/project/{project_id}/version");
|
||||
|
||||
// Add query parameters
|
||||
let mut params = vec![];
|
||||
if !mc_versions.is_empty() {
|
||||
params.push(format!(
|
||||
"game_versions=[{}]",
|
||||
mc_versions
|
||||
.iter()
|
||||
.map(|v| format!("\"{v}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
));
|
||||
}
|
||||
if !loaders.is_empty() {
|
||||
params.push(format!(
|
||||
"loaders=[{}]",
|
||||
loaders
|
||||
.iter()
|
||||
.map(|l| format!("\"{l}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
));
|
||||
}
|
||||
|
||||
if !params.is_empty() {
|
||||
url.push('?');
|
||||
url.push_str(¶ms.join("&"));
|
||||
}
|
||||
|
||||
let response = self.client.get(&url).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PakkerError::ProjectNotFound(project_id.to_string()));
|
||||
}
|
||||
|
||||
let mr_versions: Vec<ModrinthVersion> = response.json().await?;
|
||||
|
||||
Ok(
|
||||
mr_versions
|
||||
.into_iter()
|
||||
.map(|v| self.convert_version(v, project_id))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn request_project_with_files(
|
||||
&self,
|
||||
identifier: &str,
|
||||
mc_versions: &[String],
|
||||
loaders: &[String],
|
||||
) -> Result<Project> {
|
||||
let mut project = self
|
||||
.request_project(identifier, mc_versions, loaders)
|
||||
.await?;
|
||||
let project_id = project
|
||||
.get_platform_id("modrinth")
|
||||
.ok_or_else(|| {
|
||||
PakkerError::InternalError("Missing modrinth ID".to_string())
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let files = self
|
||||
.request_project_files(&project_id, mc_versions, loaders)
|
||||
.await?;
|
||||
project.files = files;
|
||||
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
async fn lookup_by_hash(&self, hash: &str) -> Result<Option<Project>> {
|
||||
// Modrinth uses SHA-1 hash for file lookups
|
||||
let url = format!("{MODRINTH_API_BASE}/version_file/{hash}");
|
||||
|
||||
let response = self.client.get(&url).send().await?;
|
||||
|
||||
if response.status().as_u16() == 404 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PakkerError::PlatformApiError(format!(
|
||||
"Modrinth API error: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let version_data: serde_json::Value = response.json().await?;
|
||||
|
||||
let project_id = version_data["project_id"].as_str().ok_or_else(|| {
|
||||
PakkerError::InvalidResponse("Missing project_id".to_string())
|
||||
})?;
|
||||
|
||||
self
|
||||
.request_project_with_files(project_id, &[], &[])
|
||||
.await
|
||||
.map(Some)
|
||||
}
|
||||
}
|
||||
|
||||
// Modrinth API models
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct ModrinthProject {
|
||||
id: String,
|
||||
slug: String,
|
||||
title: String,
|
||||
#[serde(rename = "project_type")]
|
||||
project_type: String,
|
||||
client_side: String,
|
||||
server_side: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct ModrinthVersion {
|
||||
id: String,
|
||||
project_id: String,
|
||||
name: String,
|
||||
version_number: String,
|
||||
game_versions: Vec<String>,
|
||||
version_type: String,
|
||||
loaders: Vec<String>,
|
||||
date_published: String,
|
||||
files: Vec<ModrinthFile>,
|
||||
dependencies: Vec<ModrinthDependency>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct ModrinthFile {
|
||||
hashes: HashMap<String, String>,
|
||||
url: String,
|
||||
filename: String,
|
||||
primary: bool,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct ModrinthDependency {
|
||||
project_id: Option<String>,
|
||||
dependency_type: String,
|
||||
}
|
||||
32
src/platform/traits.rs
Normal file
32
src/platform/traits.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
use crate::{error::Result, model::Project};
|
||||
|
||||
#[async_trait]
|
||||
pub trait PlatformClient: Send + Sync {
|
||||
/// Request a single project by identifier
|
||||
async fn request_project(
|
||||
&self,
|
||||
project_id: &str,
|
||||
mc_versions: &[String],
|
||||
loaders: &[String],
|
||||
) -> Result<Project>;
|
||||
|
||||
/// Request files for a project
|
||||
async fn request_project_files(
|
||||
&self,
|
||||
project_id: &str,
|
||||
mc_versions: &[String],
|
||||
loaders: &[String],
|
||||
) -> Result<Vec<crate::model::ProjectFile>>;
|
||||
|
||||
/// Request a project with its files
|
||||
async fn request_project_with_files(
|
||||
&self,
|
||||
project_id: &str,
|
||||
mc_versions: &[String],
|
||||
loaders: &[String],
|
||||
) -> Result<Project>;
|
||||
|
||||
async fn lookup_by_hash(&self, hash: &str) -> Result<Option<Project>>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue