pakker/crates/pakker-cli/src/cli/commands/add_prj.rs
NotAShelf da15ebf9bd
various: clean up multiplatform mod resolution; add lockfile management
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If1fed3ad9f9273266ae6e0e24d57b6996a6a6964
2026-05-03 22:12:05 +03:00

453 lines
13 KiB
Rust

use std::{collections::HashMap, path::Path, time::Duration};
use indicatif::{ProgressBar, ProgressStyle};
use yansi::Paint;
use crate::{
error::{PakkerError, Result},
model::{
Config,
LockFile,
Project,
credentials::ResolvedCredentials,
enums::{ProjectSide, ProjectType, UpdateStrategy},
},
platform::create_platform,
resolver::DependencyResolver,
ui_utils::prompt_curseforge_api_key,
};
/// Parse a common project argument (slug or ID with optional file ID)
/// Format: "input" or "`input#file_id`"
fn parse_common_arg(input: &str) -> (String, Option<String>) {
if let Some((project_input, file_id)) = input.split_once('#') {
(project_input.to_string(), Some(file_id.to_string()))
} else {
(input.to_string(), None)
}
}
/// Parse a GitHub argument (owner/repo with optional tag)
/// Format: "owner/repo" or "owner/repo#tag"
fn parse_github_arg(input: &str) -> Result<(String, String, Option<String>)> {
let (repo_part, tag) = if let Some((r, t)) = input.split_once('#') {
(r, Some(t.to_string()))
} else {
(input, None)
};
if let Some((owner, repo)) = repo_part.split_once('/') {
Ok((owner.to_string(), repo.to_string(), tag))
} else {
Err(PakkerError::InvalidInput(format!(
"Invalid GitHub format '{input}'. Expected: owner/repo or owner/repo#tag"
)))
}
}
fn get_loaders(lockfile: &LockFile) -> Vec<String> {
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<String>,
mr_arg: Option<String>,
gh_arg: Option<String>,
project_type: Option<ProjectType>,
project_side: Option<ProjectSide>,
update_strategy: Option<UpdateStrategy>,
redistributable: Option<bool>,
subpath: Option<String>,
aliases: Vec<String>,
export: Option<bool>,
no_deps: bool,
yes: bool,
lockfile_path: &Path,
config_path: &Path,
) -> Result<()> {
// At least one platform must be specified
if cf_arg.is_none() && mr_arg.is_none() && gh_arg.is_none() {
return Err(PakkerError::InvalidInput(
"At least one platform must be specified (--cf, --mr, or --gh)"
.to_string(),
));
}
log::info!("Adding project with explicit platform specification");
// Load lockfile
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)?;
// Load config if available
let _config = Config::load(config_dir).ok();
// Get MC versions and loaders from lockfile
let mc_versions = &lockfile.mc_versions;
let loaders = get_loaders(&lockfile);
// Fetch projects from each specified platform
let mut projects_to_merge: Vec<Project> = Vec::new();
// CurseForge
if let Some(cf_input) = cf_arg {
log::info!("Fetching from CurseForge: {cf_input}");
let (input, file_id) = parse_common_arg(&cf_input);
let credentials = ResolvedCredentials::load();
let mut cf_api_key = credentials.curseforge_api_key().map(String::from);
// Prompt for missing CurseForge credentials
if cf_api_key.is_none() && !yes {
if let Some(key) = prompt_curseforge_api_key(false)? {
cf_api_key = Some(key);
}
}
let platform = create_platform("curseforge", cf_api_key)?;
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} {msg}")
.expect("spinner template is valid"),
);
spinner.enable_steady_tick(Duration::from_millis(80));
spinner.set_message("Fetching from CurseForge...");
let mut project = platform
.request_project_with_files(&input, mc_versions, &loaders)
.await
.map_err(|e| {
PakkerError::ProjectNotFound(format!(
"CurseForge project '{input}': {e}"
))
})?;
spinner.finish_and_clear();
// If file_id specified, filter to that file
if let Some(fid) = file_id {
project.files.retain(|f| f.id == fid);
if project.files.is_empty() {
return Err(PakkerError::FileSelectionError(format!(
"File ID '{fid}' not found for CurseForge project '{input}'"
)));
}
}
projects_to_merge.push(project);
spinner.finish_and_clear();
}
// Modrinth
if let Some(mr_input) = mr_arg {
log::info!("Fetching from Modrinth: {mr_input}");
let (input, file_id) = parse_common_arg(&mr_input);
let platform = create_platform("modrinth", None)?;
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} {msg}")
.expect("spinner template is valid"),
);
spinner.enable_steady_tick(Duration::from_millis(80));
spinner.set_message("Fetching from Modrinth...");
let mut project = platform
.request_project_with_files(&input, mc_versions, &loaders)
.await
.map_err(|e| {
PakkerError::ProjectNotFound(format!("Modrinth project '{input}': {e}"))
})?;
// If file_id specified, filter to that file
if let Some(fid) = file_id {
project.files.retain(|f| f.id == fid);
if project.files.is_empty() {
return Err(PakkerError::FileSelectionError(format!(
"File ID '{fid}' not found for Modrinth project '{input}'"
)));
}
}
spinner.finish_and_clear();
projects_to_merge.push(project);
}
// GitHub
if let Some(gh_input) = gh_arg {
log::info!("Fetching from GitHub: {gh_input}");
let (owner, repo, tag) = parse_github_arg(&gh_input)?;
let gh_token = std::env::var("GITHUB_TOKEN").ok();
let platform = create_platform("github", gh_token)?;
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} {msg}")
.expect("spinner template is valid"),
);
spinner.enable_steady_tick(Duration::from_millis(80));
spinner.set_message("Fetching from GitHub...");
let repo_path = format!("{owner}/{repo}");
let mut project = platform
.request_project_with_files(&repo_path, mc_versions, &loaders)
.await
.map_err(|e| {
PakkerError::ProjectNotFound(format!(
"GitHub repository '{owner}/{repo}': {e}"
))
})?;
// If tag specified, filter to that tag
if let Some(t) = tag {
project.files.retain(|f| f.id == t);
if project.files.is_empty() {
return Err(PakkerError::FileSelectionError(format!(
"Tag '{t}' not found for GitHub repository '{owner}/{repo}'"
)));
}
}
spinner.finish_and_clear();
projects_to_merge.push(project);
}
// Merge all fetched projects into one
if projects_to_merge.is_empty() {
return Err(PakkerError::ProjectNotFound(
"No projects could be fetched from specified platforms".to_string(),
));
}
let mut combined_project = projects_to_merge.remove(0);
for project in projects_to_merge {
combined_project = combined_project.merged(project)?;
}
// Apply user-specified properties
if let Some(pt) = project_type {
combined_project.r#type = pt;
}
if let Some(ps) = project_side {
combined_project.side = ps;
}
if let Some(us) = update_strategy {
combined_project.update_strategy = us;
}
if let Some(r) = redistributable {
combined_project.redistributable = r;
}
if let Some(sp) = subpath {
combined_project.subpath = Some(sp);
}
if let Some(e) = export {
combined_project.export = e;
}
// Add aliases
for alias in aliases {
combined_project.aliases.insert(alias);
}
// Check if project already exists
let existing_pos = lockfile.projects.iter().position(|p| {
// Check if any platform ID matches
combined_project.id.iter().any(|(platform, id)| {
p.id
.get(platform)
.is_some_and(|existing_id| existing_id == id)
})
});
let project_name = combined_project.get_name();
if let Some(pos) = existing_pos {
let existing_project = &lockfile.projects[pos];
let existing_name = existing_project.get_name();
if !yes {
let prompt_msg = format!(
"Project '{existing_name}' already exists. Replace with \
'{project_name}'?"
);
if !crate::ui_utils::prompt_yes_no(&prompt_msg, false, yes)? {
log::info!("Operation cancelled by user");
return Ok(());
}
}
log::info!("Replacing existing project: {existing_name}");
lockfile.projects[pos] = combined_project.clone();
println!(
"{}",
format!("✓ Replaced '{existing_name}' with '{project_name}'").green()
);
} else {
if !yes {
let prompt_msg = format!("Add project '{project_name}'?");
if !crate::ui_utils::prompt_yes_no(&prompt_msg, true, yes)? {
log::info!("Operation cancelled by user");
return Ok(());
}
}
lockfile.add_project(combined_project.clone());
println!("{}", format!("✓ Added '{project_name}'").green());
}
// Resolve dependencies unless --no-deps is specified
if !no_deps {
log::info!("Resolving dependencies...");
let dep_spinner = ProgressBar::new_spinner();
dep_spinner.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} {msg}")
.expect("spinner template is valid"),
);
dep_spinner.enable_steady_tick(Duration::from_millis(80));
dep_spinner.set_message("Resolving dependencies...");
let platforms = create_all_platforms();
let mut resolver = DependencyResolver::new();
let deps = resolver
.resolve(&mut combined_project, &mut lockfile, &platforms)
.await?;
dep_spinner.finish_and_clear();
for dep in deps {
// Skip if already in lockfile
if lockfile.projects.iter().any(|p| {
dep.id.iter().any(|(platform, id)| {
p.id
.get(platform)
.is_some_and(|existing_id| existing_id == id)
})
}) {
continue;
}
let dep_name = dep.get_name();
// Prompt user for confirmation unless --yes flag is set
if !yes {
let prompt_msg =
format!("Add dependency '{dep_name}' required by '{project_name}'?");
if !crate::ui_utils::prompt_yes_no(&prompt_msg, true, yes)? {
log::info!("Skipping dependency: {dep_name}");
continue;
}
}
log::info!("Adding dependency: {dep_name}");
lockfile.add_project(dep);
println!("{}", format!(" ✓ Added dependency '{dep_name}'").green());
}
}
// Save lockfile
lockfile.save(lockfile_dir)?;
log::info!("Successfully completed add-prj operation");
Ok(())
}
fn create_all_platforms()
-> HashMap<String, Box<dyn crate::platform::PlatformClient>> {
let mut platforms = HashMap::new();
let credentials = ResolvedCredentials::load();
let curseforge_key = credentials.curseforge_api_key().map(String::from);
let github_token = credentials.github_access_token().map(String::from);
if let Ok(platform) = create_platform("multiplatform", curseforge_key) {
platforms.insert("multiplatform".to_string(), platform);
} else if let Ok(platform) = create_platform("modrinth", None) {
platforms.insert("modrinth".to_string(), platform);
}
if let Ok(platform) = create_platform("github", github_token) {
platforms.insert("github".to_string(), platform);
}
platforms
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_common_arg_without_file_id() {
let (input, file_id) = parse_common_arg("fabric-api");
assert_eq!(input, "fabric-api");
assert_eq!(file_id, None);
}
#[test]
fn test_parse_common_arg_with_file_id() {
let (input, file_id) = parse_common_arg("fabric-api#12345");
assert_eq!(input, "fabric-api");
assert_eq!(file_id, Some("12345".to_string()));
}
#[test]
fn test_parse_github_arg_owner_repo() {
let result = parse_github_arg("FabricMC/fabric");
assert!(result.is_ok());
let (owner, repo, tag) = result.unwrap();
assert_eq!(owner, "FabricMC");
assert_eq!(repo, "fabric");
assert_eq!(tag, None);
}
#[test]
fn test_parse_github_arg_with_tag() {
let result = parse_github_arg("FabricMC/fabric#v0.15.0");
assert!(result.is_ok());
let (owner, repo, tag) = result.unwrap();
assert_eq!(owner, "FabricMC");
assert_eq!(repo, "fabric");
assert_eq!(tag, Some("v0.15.0".to_string()));
}
#[test]
fn test_parse_github_arg_invalid() {
let result = parse_github_arg("invalid-format");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Invalid GitHub format")
);
}
#[test]
fn test_parse_github_arg_missing_repo() {
let result = parse_github_arg("FabricMC/");
assert!(result.is_ok());
let (owner, repo, tag) = result.unwrap();
assert_eq!(owner, "FabricMC");
assert_eq!(repo, "");
assert_eq!(tag, None);
}
}