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) { 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)> { 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 { 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, gh_arg: Option, project_type: Option, project_side: Option, update_strategy: Option, redistributable: Option, subpath: Option, aliases: Vec, export: Option, 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 = 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.merge(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> { 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); } }