use std::{collections::HashMap, time::Duration}; use crate::{ error::{MultiError, PakkerError, Result}, http, model::{ Config, LockFile, PakkerCredentialsFile, Project, Target, credentials::ResolvedCredentials, set_keyring_secret, }, platform::create_platform, resolver::DependencyResolver, ui_utils::prompt_curseforge_api_key, }; fn get_loaders(lockfile: &LockFile) -> Vec { lockfile.loaders.keys().cloned().collect() } fn needs_curseforge(target: Option<&Target>) -> bool { matches!( target, Some(Target::CurseForge) | Some(Target::Multiplatform) ) } async fn ensure_curseforge_credentials() -> Result { let creds = ResolvedCredentials::load(); if creds.curseforge_api_key().is_some() { return Ok(true); } if let Some(key) = prompt_curseforge_api_key(false)? { // Verify the key before saving let client = http::create_http_client(); let response = client .get("https://api.curseforge.com/v1/mods/238222") .header("x-api-key", &key) .timeout(Duration::from_secs(10)) .send() .await; match response { Ok(resp) if resp.status().is_success() => { let mut creds_file = PakkerCredentialsFile::load()?; set_keyring_secret("curseforge_api_key", &key)?; creds_file.curseforge_api_key = Some(key.clone()); creds_file.save()?; println!("CurseForge API key verified and saved."); Ok(true) }, Ok(resp) => { println!( "Warning: CurseForge API key verification failed (HTTP {}).", resp.status() ); if crate::ui_utils::prompt_yes_no( "Save this key anyway?", false, false, )? { let mut creds_file = PakkerCredentialsFile::load()?; set_keyring_secret("curseforge_api_key", &key)?; creds_file.curseforge_api_key = Some(key); creds_file.save()?; Ok(true) } else { Ok(false) } }, Err(e) => { println!("Warning: Could not verify CurseForge API key: {e}"); if crate::ui_utils::prompt_yes_no( "Save this key anyway?", false, false, )? { let mut creds_file = PakkerCredentialsFile::load()?; set_keyring_secret("curseforge_api_key", &key)?; creds_file.curseforge_api_key = Some(key); creds_file.save()?; Ok(true) } else { Ok(false) } }, } } else { Ok(false) } } pub fn create_all_platforms() -> HashMap> { let mut platforms = HashMap::new(); let credentials = ResolvedCredentials::load(); let curseforge_key = credentials.curseforge_api_key().map(String::from); if let Ok(platform) = create_platform("multiplatform", curseforge_key) { platforms.insert("multiplatform".to_owned(), platform); } else if let Ok(platform) = create_platform("modrinth", None) { platforms.insert("modrinth".to_owned(), platform); } platforms } async fn resolve_input( input: &str, platforms: &HashMap>, lockfile: &LockFile, ) -> Result { let mut projects = Vec::new(); for (platform_name, client) in platforms { match client .request_project_with_files( input, &lockfile.mc_versions, &get_loaders(lockfile), ) .await { Ok(project) => { log::debug!("Resolved '{input}' on {platform_name}"); projects.push(project); }, Err(e) => { log::debug!("Could not resolve '{input}' on {platform_name}: {e}"); }, } } 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 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, lockfile_path: &Path, config_path: &Path, ) -> Result<()> { let skip_prompts = global_yes; log::info!("Adding projects: {:?}", args.inputs); // Load lockfile // Load expects directory path, so get parent directory 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 = lockfile_path.exists() || lockfile_dir.join("pakku-lock.json").exists(); if !lockfile_exists { // Try to load config from both pakker.json and pakku.json let local_config = LocalConfig::load(config_dir).or_else(|_| { let legacy_config_path = config_dir.join("pakku.json"); if legacy_config_path.exists() { LocalConfig::load(&config_dir.join("pakku.json")) } else { Err(PakkerError::IoError(std::io::Error::new( std::io::ErrorKind::NotFound, "No pakker.json found", ))) } })?; if local_config.has_parent() { log::info!("Creating minimal fork lockfile with parent metadata..."); // Check for parent lockfile (try both pakker-lock.json and // pakku-lock.json) let parent_paths = [ lockfile_dir.join(".pakku/parent/pakker-lock.json"), lockfile_dir.join(".pakku/parent/pakku-lock.json"), ]; let parent_found = parent_paths.iter().any(|path| path.exists()); if !parent_found { return Err(PakkerError::IoError(std::io::Error::new( std::io::ErrorKind::NotFound, "Fork configured but parent lockfile not found at \ .pakku/parent/pakker-lock.json or .pakku/parent/pakku-lock.json", ))); } // Load parent lockfile to get metadata let parent_lock_path = parent_paths .iter() .find(|path| path.exists()) .ok_or_else(|| { PakkerError::IoError(std::io::Error::new( std::io::ErrorKind::NotFound, "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 { target: parent_lockfile.target, mc_versions: parent_lockfile.mc_versions, loaders: parent_lockfile.loaders, projects: Vec::new(), lockfile_version: 1, }; minimal_lockfile.save_without_validation(lockfile_dir)?; } else { return Err(PakkerError::IoError(std::io::Error::new( std::io::ErrorKind::NotFound, "pakker-lock.json not found and no fork configured. Run 'pakker init' \ first.", ))); } } let mut lockfile = LockFile::load_with_validation(lockfile_dir, false)?; // Prompt for missing CurseForge credentials when needed if needs_curseforge(lockfile.target.as_ref()) && !skip_prompts { let _ = ensure_curseforge_credentials().await; } // Load config if available let _config = Config::load(config_dir).ok(); // Create platforms let platforms = create_all_platforms(); let mut new_projects = Vec::new(); let mut errors = MultiError::new(); // Resolve each input for input in &args.inputs { let project = match resolve_input(input, &platforms, &lockfile).await { Ok(p) => p, Err(e) => { // Collect error but continue with other inputs log::warn!("Failed to resolve '{input}': {e}"); errors.push(e); continue; }, }; // Check if already exists by matching platform IDs (not pakku_id which is // random) let already_exists = lockfile.projects.iter().any(|p| { // Check if any platform ID matches project.id.iter().any(|(platform, id)| { p.id .get(platform) .is_some_and(|existing_id| existing_id == id) }) }); if already_exists { if args.update { log::info!("Updating existing project: {}", project.get_name()); // Find and replace the existing project if let Some(pos) = lockfile.projects.iter().position(|p| { project.id.iter().any(|(platform, id)| { p.id .get(platform) .is_some_and(|existing_id| existing_id == id) }) }) { lockfile.projects[pos] = project; } continue; } log::info!("Project already exists: {}", project.get_name()); continue; } // Prompt for confirmation unless --yes flag is set if !skip_prompts { let prompt_msg = format!("Add project '{}'?", project.get_name()); if !crate::ui_utils::prompt_yes_no(&prompt_msg, true, skip_prompts)? { log::info!("Skipping project: {}", project.get_name()); continue; } } new_projects.push(project); } // Resolve dependencies unless --no-deps is specified if !args.no_deps { log::info!("Resolving dependencies..."); let mut resolver = DependencyResolver::new(); let mut all_new_projects = new_projects.clone(); for project in &mut new_projects { let deps = resolver.resolve(project, &mut lockfile, &platforms).await?; for dep in deps { if !lockfile.projects.iter().any(|p| p.pakku_id == dep.pakku_id) && !all_new_projects.iter().any(|p| p.pakku_id == dep.pakku_id) { // Prompt user for confirmation unless --yes flag is set if !skip_prompts { let prompt_msg = format!( "Add dependency '{}' required by '{}'?", dep.get_name(), project.get_name() ); if !crate::ui_utils::prompt_yes_no(&prompt_msg, true, skip_prompts)? { log::info!("Skipping dependency: {}", dep.get_name()); continue; } } log::info!("Adding dependency: {}", dep.get_name()); all_new_projects.push(dep); } } } new_projects = all_new_projects; } // Track count before moving let added_count = new_projects.len(); // Add projects to lockfile (updates already handled above) for project in new_projects { lockfile.add_project(project); } // Save lockfile lockfile.save(lockfile_dir)?; log::info!("Successfully added {added_count} project(s)"); // Return aggregated errors if any occurred if !errors.is_empty() { let error_count = errors.len(); log::warn!( "{error_count} project(s) failed to resolve (see warnings above)" ); // Return success if at least some projects were added, otherwise return // errors if added_count == 0 && args.inputs.len() == error_count { return errors.into_result(()); } } Ok(()) }