diff --git a/src/cli.rs b/src/cli.rs index fea5e72..8677bdf 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -15,6 +15,10 @@ pub struct Cli { #[clap(short, long, action = clap::ArgAction::Count)] pub verbose: u8, + /// Skip all confirmation prompts (assume yes) + #[clap(short, long, global = true)] + pub yes: bool, + #[clap(subcommand)] pub command: Commands, } @@ -107,10 +111,6 @@ pub struct InitArgs { /// Mod loaders (format: name=version, can be specified multiple times) #[clap(short, long = "loaders", value_delimiter = ',')] pub loaders: Option>, - - /// Skip interactive prompts (use defaults) - #[clap(short, long)] - pub yes: bool, } #[derive(Args)] @@ -121,10 +121,6 @@ pub struct ImportArgs { /// Resolve dependencies #[clap(short = 'D', long = "deps")] pub deps: bool, - - /// Skip confirmation prompts - #[clap(short, long)] - pub yes: bool, } #[derive(Args)] @@ -144,10 +140,6 @@ pub struct AddArgs { /// Update if already exists #[clap(short, long)] pub update: bool, - - /// Skip confirmation prompts - #[clap(short, long)] - pub yes: bool, } #[derive(Args)] @@ -195,10 +187,6 @@ pub struct AddPrjArgs { /// Skip resolving dependencies #[clap(short = 'D', long = "no-deps")] pub no_deps: bool, - - /// Skip confirmation prompts - #[clap(short, long)] - pub yes: bool, } #[derive(Args)] @@ -211,10 +199,6 @@ pub struct RmArgs { #[clap(short = 'a', long)] pub all: bool, - /// Skip confirmation prompt - #[clap(short, long)] - pub yes: bool, - /// Skip removing dependent projects #[clap(short = 'D', long = "no-deps")] pub no_deps: bool, @@ -229,10 +213,6 @@ pub struct UpdateArgs { /// Update all projects #[arg(short, long)] pub all: bool, - - /// Skip confirmation prompts - #[arg(short, long)] - pub yes: bool, } #[derive(Args)] diff --git a/src/cli/commands/add.rs b/src/cli/commands/add.rs index 625a6cb..9eb3780 100644 --- a/src/cli/commands/add.rs +++ b/src/cli/commands/add.rs @@ -57,9 +57,11 @@ use crate::{cli::AddArgs, model::fork::LocalConfig}; 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 @@ -187,9 +189,9 @@ pub async fn execute( } // Prompt for confirmation unless --yes flag is set - if !args.yes { + if !skip_prompts { let prompt_msg = format!("Add project '{}'?", project.get_name()); - if !crate::ui_utils::prompt_yes_no(&prompt_msg, true)? { + if !crate::ui_utils::prompt_yes_no(&prompt_msg, true, skip_prompts)? { log::info!("Skipping project: {}", project.get_name()); continue; } @@ -213,13 +215,14 @@ pub async fn execute( && !all_new_projects.iter().any(|p| p.pakku_id == dep.pakku_id) { // Prompt user for confirmation unless --yes flag is set - if !args.yes { + 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)? { + if !crate::ui_utils::prompt_yes_no(&prompt_msg, true, skip_prompts)? + { log::info!("Skipping dependency: {}", dep.get_name()); continue; } diff --git a/src/cli/commands/add_prj.rs b/src/cli/commands/add_prj.rs index f111331..fd3166c 100644 --- a/src/cli/commands/add_prj.rs +++ b/src/cli/commands/add_prj.rs @@ -232,7 +232,7 @@ pub async fn execute( "Project '{existing_name}' already exists. Replace with \ '{project_name}'?" ); - if !crate::ui_utils::prompt_yes_no(&prompt_msg, false)? { + if !crate::ui_utils::prompt_yes_no(&prompt_msg, false, yes)? { log::info!("Operation cancelled by user"); return Ok(()); } @@ -244,7 +244,7 @@ pub async fn execute( } else { if !yes { let prompt_msg = format!("Add project '{project_name}'?"); - if !crate::ui_utils::prompt_yes_no(&prompt_msg, true)? { + if !crate::ui_utils::prompt_yes_no(&prompt_msg, true, yes)? { log::info!("Operation cancelled by user"); return Ok(()); } @@ -283,7 +283,7 @@ pub async fn execute( if !yes { let prompt_msg = format!("Add dependency '{dep_name}' required by '{project_name}'?"); - if !crate::ui_utils::prompt_yes_no(&prompt_msg, true)? { + if !crate::ui_utils::prompt_yes_no(&prompt_msg, true, yes)? { log::info!("Skipping dependency: {dep_name}"); continue; } diff --git a/src/cli/commands/cfg_prj.rs b/src/cli/commands/cfg_prj.rs index 3ad7346..0c9018f 100644 --- a/src/cli/commands/cfg_prj.rs +++ b/src/cli/commands/cfg_prj.rs @@ -32,7 +32,7 @@ pub fn execute( // Find the project in lockfile to get its pakku_id // Try multiple lookup strategies: pakku_id first, then slug, then name let found_project = lockfile - .find_project(&project) + .get_project(&project) .or_else(|| { // Try to find by slug on any platform lockfile diff --git a/src/cli/commands/import.rs b/src/cli/commands/import.rs index c9a20da..12f3113 100644 --- a/src/cli/commands/import.rs +++ b/src/cli/commands/import.rs @@ -9,9 +9,11 @@ use crate::{ pub async fn execute( args: ImportArgs, + global_yes: bool, lockfile_path: &Path, config_path: &Path, ) -> Result<()> { + let skip_prompts = global_yes; log::info!("Importing modpack from {}", args.file); log::info!( "Dependency resolution: {}", @@ -27,7 +29,7 @@ pub async fn execute( } // Check if lockfile or config already exist - if (lockfile_path.exists() || config_path.exists()) && !args.yes { + if (lockfile_path.exists() || config_path.exists()) && !skip_prompts { let msg = if lockfile_path.exists() && config_path.exists() { "Both pakku-lock.json and pakku.json exist. Importing will overwrite \ them. Continue?" @@ -37,7 +39,7 @@ pub async fn execute( "pakku.json exists. Importing will overwrite it. Continue?" }; - if !prompt_yes_no(msg, false)? { + if !prompt_yes_no(msg, false, skip_prompts)? { log::info!("Import cancelled by user"); return Ok(()); } @@ -146,6 +148,7 @@ async fn import_modrinth( if let Err(e) = project.select_file( &lockfile.mc_versions, std::slice::from_ref(&loader.0), + None, // Use default (1 file) during import ) { log::warn!( "Failed to select file for {}: {}", @@ -185,6 +188,7 @@ async fn import_modrinth( projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; // Save files using provided paths @@ -314,6 +318,7 @@ async fn import_curseforge( if let Err(e) = project.select_file( &lockfile.mc_versions, &loaders.keys().cloned().collect::>(), + None, // Use default (1 file) during import ) { log::warn!( "Failed to select file for {}: {}", @@ -328,6 +333,7 @@ async fn import_curseforge( if let Err(e) = project.select_file( &lockfile.mc_versions, &loaders.keys().cloned().collect::>(), + None, // Use default (1 file) during import ) { log::warn!( "Failed to select file for {}: {}", @@ -368,6 +374,7 @@ async fn import_curseforge( projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; // Save files using provided paths diff --git a/src/cli/commands/init.rs b/src/cli/commands/init.rs index d0da1ed..13858ef 100644 --- a/src/cli/commands/init.rs +++ b/src/cli/commands/init.rs @@ -14,9 +14,12 @@ use crate::{ pub async fn execute( args: InitArgs, + global_yes: bool, lockfile_path: &Path, config_path: &Path, ) -> Result<(), PakkerError> { + let skip_prompts = global_yes; + if lockfile_path.exists() { return Err(PakkerError::AlreadyExists( "Lock file already exists".into(), @@ -24,7 +27,7 @@ pub async fn execute( } // Interactive mode: prompt for values not provided via CLI and --yes not set - let is_interactive = !args.yes && args.name.is_none(); + let is_interactive = !skip_prompts && args.name.is_none(); // Get modpack name let name = if let Some(name) = args.name.clone() { @@ -137,6 +140,7 @@ pub async fn execute( projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; let config_dir = config_path.parent().unwrap_or(Path::new(".")); @@ -164,9 +168,13 @@ pub async fn execute( if !has_cf_key { println!(); - if prompt_yes_no("Would you like to set up CurseForge API key now?", true) - .map_err(|e| PakkerError::InvalidInput(e.to_string()))? - && let Ok(Some(api_key)) = prompt_curseforge_api_key() + if prompt_yes_no( + "Would you like to set up CurseForge API key now?", + true, + skip_prompts, + ) + .map_err(|e| PakkerError::InvalidInput(e.to_string()))? + && let Ok(Some(api_key)) = prompt_curseforge_api_key(skip_prompts) { // Save to credentials file let creds_path = std::env::var("HOME").map_or_else( diff --git a/src/cli/commands/rm.rs b/src/cli/commands/rm.rs index a147d66..5189e59 100644 --- a/src/cli/commands/rm.rs +++ b/src/cli/commands/rm.rs @@ -9,9 +9,11 @@ use crate::{ pub async fn execute( args: RmArgs, + global_yes: bool, lockfile_path: &Path, _config_path: &Path, ) -> Result<()> { + let skip_prompts = global_yes; // Load expects directory path, so get parent directory let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new(".")); let mut lockfile = LockFile::load(lockfile_dir)?; @@ -79,7 +81,9 @@ pub async fn execute( resolved_inputs.push(input.clone()); } else if !args.all { // Try typo suggestion - if let Ok(Some(suggestion)) = prompt_typo_suggestion(input, &all_slugs) { + if let Ok(Some(suggestion)) = + prompt_typo_suggestion(input, &all_slugs, skip_prompts) + { log::info!("Using suggested project: {suggestion}"); resolved_inputs.push(suggestion); } else { @@ -111,13 +115,13 @@ pub async fn execute( // Ask for confirmation unless --yes flag is provided or --all with no // projects - if !args.yes { + if !skip_prompts { println!("The following projects will be removed:"); for name in &projects_to_remove { println!(" - {name}"); } - if !prompt_yes_no("Do you want to continue?", false)? { + if !prompt_yes_no("Do you want to continue?", false, skip_prompts)? { println!("Removal cancelled."); return Ok(()); } diff --git a/src/cli/commands/status.rs b/src/cli/commands/status.rs index 6b50ed6..1e157a8 100644 --- a/src/cli/commands/status.rs +++ b/src/cli/commands/status.rs @@ -13,6 +13,7 @@ use crate::{ pub async fn execute( parallel: bool, + skip_prompts: bool, lockfile_path: &Path, config_path: &Path, ) -> Result<()> { @@ -77,15 +78,15 @@ pub async fn execute( // Prompt to update if there are updates available if !updates.is_empty() { println!(); - if crate::ui_utils::prompt_yes_no("Update now?", false)? { + if crate::ui_utils::prompt_yes_no("Update now?", false, skip_prompts)? { // Call update command programmatically (update all projects) let update_args = crate::cli::UpdateArgs { inputs: vec![], all: true, - yes: true, // Auto-yes for status command }; crate::cli::commands::update::execute( update_args, + true, // Auto-yes for status command lockfile_path, config_path, ) @@ -380,17 +381,6 @@ fn display_update_results(updates: &[ProjectUpdate]) { ); } -#[allow(dead_code)] -fn get_project_display_name(project: &Project) -> String { - project - .name - .values() - .next() - .or_else(|| project.slug.values().next()) - .cloned() - .unwrap_or_else(|| "Unknown".to_string()) -} - fn get_api_key(platform: &str) -> Option { match platform { "modrinth" => std::env::var("MODRINTH_TOKEN").ok(), diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index c5404cf..e806cfc 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -1,7 +1,6 @@ use std::{ collections::{HashMap, HashSet}, fs, - io::{self, Write}, path::{Path, PathBuf}, }; @@ -22,6 +21,7 @@ enum SyncChange { pub async fn execute( args: SyncArgs, + global_yes: bool, lockfile_path: &Path, config_path: &Path, ) -> Result<()> { @@ -66,7 +66,11 @@ pub async fn execute( for (file_path, _) in &additions { spinner .set_message(format!("Processing addition: {}", file_path.display())); - if prompt_user(&format!("Add {} to lockfile?", file_path.display()))? { + if crate::ui_utils::prompt_yes_no( + &format!("Add {} to lockfile?", file_path.display()), + false, + global_yes, + )? { add_file_to_lockfile(&mut lockfile, file_path, &config).await?; } } @@ -87,7 +91,11 @@ pub async fn execute( .or(project.pakku_id.as_deref()) .unwrap_or("unknown"); spinner.set_message(format!("Processing removal: {name}")); - if prompt_user(&format!("Remove {name} from lockfile?"))? { + if crate::ui_utils::prompt_yes_no( + &format!("Remove {name} from lockfile?"), + false, + global_yes, + )? { lockfile .remove_project(pakku_id) .ok_or_else(|| PakkerError::ProjectNotFound(pakku_id.clone()))?; @@ -174,7 +182,7 @@ async fn add_file_to_lockfile( _config: &Config, ) -> Result<()> { // Try to identify the file by hash lookup - let _modrinth = ModrinthPlatform::new(); + let modrinth = ModrinthPlatform::new(); let curseforge = CurseForgePlatform::new(None); // Compute file hash @@ -186,7 +194,7 @@ async fn add_file_to_lockfile( let hash = format!("{:x}", hasher.finalize()); // Try Modrinth first (SHA-1 hash) - if let Ok(Some(project)) = _modrinth.lookup_by_hash(&hash).await { + if let Ok(Some(project)) = modrinth.lookup_by_hash(&hash).await { lockfile.add_project(project); println!("✓ Added {} (from Modrinth)", file_path.display()); return Ok(()); @@ -202,15 +210,3 @@ async fn add_file_to_lockfile( println!("⚠ Could not identify {}, skipping", file_path.display()); Ok(()) } - -fn prompt_user(message: &str) -> Result { - print!("{message} [y/N] "); - io::stdout().flush().map_err(PakkerError::IoError)?; - - let mut input = String::new(); - io::stdin() - .read_line(&mut input) - .map_err(PakkerError::IoError)?; - - Ok(input.trim().eq_ignore_ascii_case("y")) -} diff --git a/src/cli/commands/update.rs b/src/cli/commands/update.rs index f33caf2..785219b 100644 --- a/src/cli/commands/update.rs +++ b/src/cli/commands/update.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, path::Path}; +use std::path::Path; use indicatif::{ProgressBar, ProgressStyle}; @@ -6,15 +6,16 @@ use crate::{ cli::UpdateArgs, error::{MultiError, PakkerError}, model::{Config, LockFile, UpdateStrategy}, - platform::create_platform, ui_utils::{prompt_select, prompt_typo_suggestion, prompt_yes_no}, }; pub async fn execute( args: UpdateArgs, + global_yes: bool, lockfile_path: &Path, config_path: &Path, ) -> Result<(), PakkerError> { + let skip_prompts = global_yes; // Load expects directory path, so get parent directory let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new(".")); let config_dir = config_path.parent().unwrap_or(Path::new(".")); @@ -23,15 +24,7 @@ pub async fn execute( let _config = Config::load(config_dir)?; // Create platforms - let mut platforms = HashMap::new(); - if let Ok(platform) = create_platform("modrinth", None) { - platforms.insert("modrinth".to_string(), platform); - } - if let Ok(platform) = - create_platform("curseforge", std::env::var("CURSEFORGE_API_KEY").ok()) - { - platforms.insert("curseforge".to_string(), platform); - } + let platforms = super::add::create_all_platforms()?; // Collect all known project identifiers for typo suggestions let all_slugs: Vec = lockfile @@ -63,7 +56,8 @@ pub async fn execute( indices.push(idx); } else { // Try typo suggestion - if let Ok(Some(suggestion)) = prompt_typo_suggestion(input, &all_slugs) + if let Ok(Some(suggestion)) = + prompt_typo_suggestion(input, &all_slugs, skip_prompts) && let Some((idx, _)) = lockfile .projects .iter() @@ -159,17 +153,18 @@ pub async fn execute( } else { // Interactive confirmation and version selection if not using --yes // flag - let mut should_update = args.yes || args.all; + let mut should_update = skip_prompts || args.all; let mut selected_idx: Option = None; - if !args.yes && !args.all { + if !skip_prompts && !args.all { pb.suspend(|| { // First, confirm the update let prompt_msg = format!( "Update '{project_name}' from {old_file_name} to \ {new_file_name}?" ); - should_update = prompt_yes_no(&prompt_msg, true).unwrap_or(false); + should_update = + prompt_yes_no(&prompt_msg, true, skip_prompts).unwrap_or(false); // If confirmed and multiple versions available, offer selection if should_update && updated_project.files.len() > 1 { diff --git a/src/cli/tests.rs b/src/cli/tests.rs index 0716df7..e116088 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -171,6 +171,7 @@ mod tests { projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; assert!(config.export_server_side_projects_to_client.is_none()); } @@ -220,6 +221,7 @@ mod tests { projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; let json = serde_json::to_string_pretty(&config).unwrap(); diff --git a/src/export/rules.rs b/src/export/rules.rs index 582a702..bfdf040 100644 --- a/src/export/rules.rs +++ b/src/export/rules.rs @@ -1232,6 +1232,7 @@ mod tests { projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }, profile_config, export_path: PathBuf::from("/tmp/export"), @@ -1362,6 +1363,7 @@ mod tests { projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; assert_eq!(get_project_type_dir(&ProjectType::Mod, &config), "mods"); @@ -1398,6 +1400,7 @@ mod tests { projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; assert_eq!( @@ -1462,6 +1465,7 @@ mod tests { projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; let mut context = create_test_context(None); @@ -1492,6 +1496,7 @@ mod tests { projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; let mut context = create_test_context(None); diff --git a/src/main.rs b/src/main.rs index 95f0aed..a322b4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,12 +18,31 @@ mod resolver; mod ui_utils; mod utils; -use std::path::PathBuf; +use std::{env, path::PathBuf}; use clap::Parser; use cli::{Cli, Commands}; use error::PakkerError; +/// Search for pakker-lock.json in current directory and parent directories +/// Returns the directory containing pakker-lock.json, or None if not found +fn find_working_directory() -> Option { + let mut current_dir = env::current_dir().ok()?; + + loop { + let lockfile = current_dir.join("pakker-lock.json"); + if lockfile.exists() { + return Some(current_dir); + } + + // Try parent directory + if !current_dir.pop() { + // Reached filesystem root + return None; + } + } +} + #[tokio::main] async fn main() -> Result<(), PakkerError> { let cli = Cli::parse(); @@ -43,19 +62,41 @@ async fn main() -> Result<(), PakkerError> { .format_module_path(false) .init(); - let working_dir = PathBuf::from("."); + // Search for pakker-lock.json in current directory and parent directories + let working_dir = + find_working_directory().unwrap_or_else(|| PathBuf::from(".")); let lockfile_path = working_dir.join("pakker-lock.json"); let config_path = working_dir.join("pakker.json"); + let global_yes = cli.yes; + match cli.command { Commands::Init(args) => { - cli::commands::init::execute(args, &lockfile_path, &config_path).await + cli::commands::init::execute( + args, + global_yes, + &lockfile_path, + &config_path, + ) + .await }, Commands::Import(args) => { - cli::commands::import::execute(args, &lockfile_path, &config_path).await + cli::commands::import::execute( + args, + global_yes, + &lockfile_path, + &config_path, + ) + .await }, Commands::Add(args) => { - cli::commands::add::execute(args, &lockfile_path, &config_path).await + cli::commands::add::execute( + args, + global_yes, + &lockfile_path, + &config_path, + ) + .await }, Commands::AddPrj(args) => { cli::commands::add_prj::execute( @@ -70,17 +111,24 @@ async fn main() -> Result<(), PakkerError> { args.aliases, args.export, args.no_deps, - args.yes, + global_yes, &lockfile_path, &config_path, ) .await }, Commands::Rm(args) => { - cli::commands::rm::execute(args, &lockfile_path, &config_path).await + cli::commands::rm::execute(args, global_yes, &lockfile_path, &config_path) + .await }, Commands::Update(args) => { - cli::commands::update::execute(args, &lockfile_path, &config_path).await + cli::commands::update::execute( + args, + global_yes, + &lockfile_path, + &config_path, + ) + .await }, Commands::Ls(args) => cli::commands::ls::execute(args, &lockfile_path), Commands::Set(args) => { @@ -95,7 +143,13 @@ async fn main() -> Result<(), PakkerError> { cli::commands::fetch::execute(args, &lockfile_path, &config_path).await }, Commands::Sync(args) => { - cli::commands::sync::execute(args, &lockfile_path, &config_path).await + cli::commands::sync::execute( + args, + global_yes, + &lockfile_path, + &config_path, + ) + .await }, Commands::Export(args) => { cli::commands::export::execute(args, &lockfile_path, &config_path).await @@ -107,6 +161,7 @@ async fn main() -> Result<(), PakkerError> { Commands::Status(args) => { cli::commands::status::execute( args.parallel, + global_yes, &lockfile_path, &config_path, ) diff --git a/src/model/config.rs b/src/model/config.rs index 83172ae..c65a588 100644 --- a/src/model/config.rs +++ b/src/model/config.rs @@ -64,6 +64,9 @@ pub struct Config { rename = "exportServerSideProjectsToClient" )] pub export_server_side_projects_to_client: Option, + /// Number of files to select per project (defaults to 1) + #[serde(skip_serializing_if = "Option::is_none")] + pub file_count_preference: Option, } impl Default for Config { @@ -80,6 +83,7 @@ impl Default for Config { projects: Some(HashMap::new()), export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, } } } @@ -145,6 +149,7 @@ impl Config { projects: Some(pakku.projects), export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }) }, Err(e) => Err(PakkerError::InvalidConfigFile(e.to_string())), @@ -203,6 +208,7 @@ mod tests { projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; assert_eq!(config.name, "test-pack"); assert_eq!(config.version, "1.0.0"); @@ -224,6 +230,7 @@ mod tests { projects: None, export_profiles: None, export_server_side_projects_to_client: None, + file_count_preference: None, }; config.description = Some("A test modpack".to_string()); config.author = Some("Test Author".to_string()); diff --git a/src/model/lockfile.rs b/src/model/lockfile.rs index cdb3a97..dc10595 100644 --- a/src/model/lockfile.rs +++ b/src/model/lockfile.rs @@ -76,11 +76,11 @@ mod tests { lockfile.add_project(create_test_project("test-id", "test-slug")); - let found = lockfile.find_project("test-id"); + let found = lockfile.get_project("test-id"); assert!(found.is_some()); assert_eq!(found.unwrap().pakku_id, Some("test-id".to_string())); - let not_found = lockfile.find_project("nonexistent"); + let not_found = lockfile.get_project("nonexistent"); assert!(not_found.is_none()); } @@ -604,10 +604,6 @@ impl LockFile { } } - pub fn find_project(&self, pakku_id: &str) -> Option<&Project> { - self.get_project(pakku_id) - } - pub fn find_project_mut(&mut self, pakku_id: &str) -> Option<&mut Project> { self .projects diff --git a/src/model/project.rs b/src/model/project.rs index 2f0620c..f4da4aa 100644 --- a/src/model/project.rs +++ b/src/model/project.rs @@ -246,6 +246,7 @@ impl Project { &mut self, mc_versions: &[String], loaders: &[String], + file_count: Option, ) -> crate::error::Result<()> { // Filter compatible files let compatible_files: Vec<_> = self @@ -269,10 +270,9 @@ impl Project { .then_with(|| b.date_published.cmp(&a.date_published)) }); - // Keep only the best file - if let Some(best_file) = sorted_files.first() { - self.files = vec![(*best_file).clone()]; - } + // Keep the specified number of files (default to 1 if not specified) + let count = file_count.unwrap_or(1); + self.files = sorted_files.into_iter().take(count).cloned().collect(); Ok(()) } @@ -531,7 +531,7 @@ mod tests { let lockfile_mc = vec!["1.20.1".to_string()]; let lockfile_loaders = vec!["fabric".to_string()]; - let result = project.select_file(&lockfile_mc, &lockfile_loaders); + let result = project.select_file(&lockfile_mc, &lockfile_loaders, None); assert!(result.is_ok()); } diff --git a/src/ui_utils.rs b/src/ui_utils.rs index 62589b5..f293256 100644 --- a/src/ui_utils.rs +++ b/src/ui_utils.rs @@ -2,7 +2,7 @@ use std::io; -use dialoguer::{Confirm, Input, MultiSelect, Select, theme::ColorfulTheme}; +use dialoguer::{Confirm, Input, Select, theme::ColorfulTheme}; /// Creates a terminal hyperlink using OSC 8 escape sequence /// Format: \x1b]8;;\x1b\\\x1b]8;;\x1b\\ @@ -12,7 +12,16 @@ pub fn hyperlink(url: &str, text: &str) -> String { /// Prompts user with a yes/no question /// Returns true for yes, false for no -pub fn prompt_yes_no(question: &str, default: bool) -> io::Result { +/// If `skip_prompts` is true, returns the default value without prompting +pub fn prompt_yes_no( + question: &str, + default: bool, + skip_prompts: bool, +) -> io::Result { + if skip_prompts { + return Ok(default); + } + Confirm::with_theme(&ColorfulTheme::default()) .with_prompt(question) .default(default) @@ -22,7 +31,6 @@ pub fn prompt_yes_no(question: &str, default: bool) -> io::Result { /// Prompts user to select from a list of options /// Returns the index of the selected option -#[allow(dead_code)] pub fn prompt_select(question: &str, options: &[&str]) -> io::Result { Select::with_theme(&ColorfulTheme::default()) .with_prompt(question) @@ -32,28 +40,12 @@ pub fn prompt_select(question: &str, options: &[&str]) -> io::Result { .map_err(io::Error::other) } -/// Prompts user to select multiple items from a list -/// Returns the indices of the selected options -#[allow(dead_code)] -pub fn prompt_multi_select( - question: &str, - options: &[&str], -) -> io::Result> { - MultiSelect::with_theme(&ColorfulTheme::default()) - .with_prompt(question) - .items(options) - .interact() - .map_err(io::Error::other) -} - /// Creates a formatted project URL for Modrinth -#[allow(dead_code)] pub fn modrinth_project_url(slug: &str) -> String { format!("https://modrinth.com/mod/{slug}") } /// Creates a formatted project URL for `CurseForge` -#[allow(dead_code)] pub fn curseforge_project_url(project_id: &str) -> String { format!("https://www.curseforge.com/minecraft/mc-mods/{project_id}") } @@ -118,16 +110,22 @@ pub fn suggest_similar<'a>( /// Prompt user if they meant a similar project name. /// Returns `Some(suggested_name)` if user confirms, None otherwise. +/// If `skip_prompts` is true, automatically accepts the first suggestion. pub fn prompt_typo_suggestion( input: &str, candidates: &[String], + skip_prompts: bool, ) -> io::Result> { // Use a max distance based on input length for reasonable suggestions let max_distance = (input.len() / 2).clamp(2, 4); let suggestions = suggest_similar(input, candidates, max_distance); if let Some(first_suggestion) = suggestions.first() - && prompt_yes_no(&format!("Did you mean '{first_suggestion}'?"), true)? + && prompt_yes_no( + &format!("Did you mean '{first_suggestion}'?"), + true, + skip_prompts, + )? { return Ok(Some((*first_suggestion).to_string())); } @@ -164,7 +162,14 @@ pub fn prompt_input_optional(prompt: &str) -> io::Result> { /// Prompt for `CurseForge` API key when authentication fails. /// Returns the API key if provided, None if cancelled. -pub fn prompt_curseforge_api_key() -> io::Result> { +/// If `skip_prompts` is true, returns None immediately. +pub fn prompt_curseforge_api_key( + skip_prompts: bool, +) -> io::Result> { + if skip_prompts { + return Ok(None); + } + use dialoguer::Password; println!(); @@ -172,7 +177,7 @@ pub fn prompt_curseforge_api_key() -> io::Result> { println!("Get your API key from: https://console.curseforge.com/"); println!(); - if !prompt_yes_no("Would you like to enter your API key now?", true)? { + if !prompt_yes_no("Would you like to enter your API key now?", true, false)? { return Ok(None); } diff --git a/src/utils/hash.rs b/src/utils/hash.rs index 88abdde..d440b0e 100644 --- a/src/utils/hash.rs +++ b/src/utils/hash.rs @@ -10,58 +10,6 @@ use sha2::{Sha256, Sha512}; use crate::error::{PakkerError, Result}; -/// Compute Murmur2 hash (32-bit) for `CurseForge` fingerprinting -#[allow(dead_code)] -pub fn compute_murmur2_hash(data: &[u8]) -> u32 { - murmur2_hash(data, 1) -} - -/// Murmur2 hash implementation -#[allow(dead_code)] -fn murmur2_hash(data: &[u8], seed: u32) -> u32 { - const M: u32 = 0x5BD1E995; - const R: i32 = 24; - - let mut h: u32 = seed ^ (data.len() as u32); - let mut chunks = data.chunks_exact(4); - - for chunk in chunks.by_ref() { - let mut k = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); - k = k.wrapping_mul(M); - k ^= k >> R; - k = k.wrapping_mul(M); - - h = h.wrapping_mul(M); - h ^= k; - } - - let remainder = chunks.remainder(); - match remainder.len() { - 3 => { - h ^= u32::from(remainder[2]) << 16; - h ^= u32::from(remainder[1]) << 8; - h ^= u32::from(remainder[0]); - h = h.wrapping_mul(M); - }, - 2 => { - h ^= u32::from(remainder[1]) << 8; - h ^= u32::from(remainder[0]); - h = h.wrapping_mul(M); - }, - 1 => { - h ^= u32::from(remainder[0]); - h = h.wrapping_mul(M); - }, - _ => {}, - } - - h ^= h >> 13; - h = h.wrapping_mul(M); - h ^= h >> 15; - - h -} - /// Compute SHA1 hash of a file pub fn compute_sha1>(path: P) -> Result { let file = File::open(path)?; @@ -167,31 +115,6 @@ pub fn verify_hash>( mod tests { use super::*; - #[test] - fn test_murmur2_hash_deterministic() { - let data = b"hello world"; - let hash1 = compute_murmur2_hash(data); - let hash2 = compute_murmur2_hash(data); - assert_eq!(hash1, hash2, "Murmur2 hash must be deterministic"); - } - - #[test] - fn test_murmur2_hash_empty() { - let data = b""; - let hash = compute_murmur2_hash(data); - assert_ne!(hash, 0, "Empty data should produce a non-zero hash"); - } - - #[test] - fn test_murmur2_hash_different_inputs() { - let hash1 = compute_murmur2_hash(b"hello"); - let hash2 = compute_murmur2_hash(b"world"); - assert_ne!( - hash1, hash2, - "Different inputs should produce different hashes" - ); - } - #[test] fn test_sha256_bytes_deterministic() { let data = b"test data"; diff --git a/src/utils.rs b/src/utils/mod.rs similarity index 84% rename from src/utils.rs rename to src/utils/mod.rs index 582c559..86947cb 100644 --- a/src/utils.rs +++ b/src/utils/mod.rs @@ -1,6 +1,5 @@ pub mod hash; pub mod id; -pub mod prompt; pub use hash::verify_hash; pub use id::generate_pakku_id; diff --git a/src/utils/prompt.rs b/src/utils/prompt.rs deleted file mode 100644 index aa6c1dc..0000000 --- a/src/utils/prompt.rs +++ /dev/null @@ -1,56 +0,0 @@ -use std::io::{self, Write}; - -use crate::error::Result; - -#[allow(dead_code)] -pub fn prompt_user(message: &str) -> Result { - print!("{message}"); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - Ok(input.trim().to_string()) -} - -#[allow(dead_code)] -pub fn prompt_select(message: &str, options: &[String]) -> Result { - println!("{message}"); - for (i, option) in options.iter().enumerate() { - println!(" {}. {}", i + 1, option); - } - - loop { - print!("Select (1-{}): ", options.len()); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - if let Ok(choice) = input.trim().parse::() - && choice > 0 - && choice <= options.len() - { - return Ok(choice - 1); - } - - println!("Invalid selection. Please try again."); - } -} - -#[allow(dead_code)] -pub fn prompt_confirm(message: &str) -> Result { - print!("{message} (y/n): "); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - let answer = input.trim().to_lowercase(); - Ok(answer == "y" || answer == "yes") -} - -#[allow(dead_code)] -pub fn confirm(message: &str) -> Result { - prompt_confirm(message) -}