diff --git a/src/cli.rs b/src/cli.rs index 8677bdf..fea5e72 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -15,10 +15,6 @@ 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, } @@ -111,6 +107,10 @@ 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,6 +121,10 @@ pub struct ImportArgs { /// Resolve dependencies #[clap(short = 'D', long = "deps")] pub deps: bool, + + /// Skip confirmation prompts + #[clap(short, long)] + pub yes: bool, } #[derive(Args)] @@ -140,6 +144,10 @@ pub struct AddArgs { /// Update if already exists #[clap(short, long)] pub update: bool, + + /// Skip confirmation prompts + #[clap(short, long)] + pub yes: bool, } #[derive(Args)] @@ -187,6 +195,10 @@ 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)] @@ -199,6 +211,10 @@ 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, @@ -213,6 +229,10 @@ 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 9eb3780..625a6cb 100644 --- a/src/cli/commands/add.rs +++ b/src/cli/commands/add.rs @@ -57,11 +57,9 @@ 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 @@ -189,9 +187,9 @@ pub async fn execute( } // Prompt for confirmation unless --yes flag is set - if !skip_prompts { + if !args.yes { let prompt_msg = format!("Add project '{}'?", project.get_name()); - if !crate::ui_utils::prompt_yes_no(&prompt_msg, true, skip_prompts)? { + if !crate::ui_utils::prompt_yes_no(&prompt_msg, true)? { log::info!("Skipping project: {}", project.get_name()); continue; } @@ -215,14 +213,13 @@ 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 !skip_prompts { + if !args.yes { 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)? - { + if !crate::ui_utils::prompt_yes_no(&prompt_msg, true)? { 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 fd3166c..f111331 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, yes)? { + if !crate::ui_utils::prompt_yes_no(&prompt_msg, false)? { 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, yes)? { + if !crate::ui_utils::prompt_yes_no(&prompt_msg, true)? { 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, yes)? { + if !crate::ui_utils::prompt_yes_no(&prompt_msg, true)? { log::info!("Skipping dependency: {dep_name}"); continue; } diff --git a/src/cli/commands/cfg_prj.rs b/src/cli/commands/cfg_prj.rs index 0c9018f..3ad7346 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 - .get_project(&project) + .find_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 12f3113..c9a20da 100644 --- a/src/cli/commands/import.rs +++ b/src/cli/commands/import.rs @@ -9,11 +9,9 @@ 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: {}", @@ -29,7 +27,7 @@ pub async fn execute( } // Check if lockfile or config already exist - if (lockfile_path.exists() || config_path.exists()) && !skip_prompts { + if (lockfile_path.exists() || config_path.exists()) && !args.yes { let msg = if lockfile_path.exists() && config_path.exists() { "Both pakku-lock.json and pakku.json exist. Importing will overwrite \ them. Continue?" @@ -39,7 +37,7 @@ pub async fn execute( "pakku.json exists. Importing will overwrite it. Continue?" }; - if !prompt_yes_no(msg, false, skip_prompts)? { + if !prompt_yes_no(msg, false)? { log::info!("Import cancelled by user"); return Ok(()); } @@ -148,7 +146,6 @@ 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 {}: {}", @@ -188,7 +185,6 @@ 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 @@ -318,7 +314,6 @@ 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 {}: {}", @@ -333,7 +328,6 @@ 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 {}: {}", @@ -374,7 +368,6 @@ 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 13858ef..d0da1ed 100644 --- a/src/cli/commands/init.rs +++ b/src/cli/commands/init.rs @@ -14,12 +14,9 @@ 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(), @@ -27,7 +24,7 @@ pub async fn execute( } // Interactive mode: prompt for values not provided via CLI and --yes not set - let is_interactive = !skip_prompts && args.name.is_none(); + let is_interactive = !args.yes && args.name.is_none(); // Get modpack name let name = if let Some(name) = args.name.clone() { @@ -140,7 +137,6 @@ 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(".")); @@ -168,13 +164,9 @@ pub async fn execute( if !has_cf_key { println!(); - 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) + 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() { // 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 5189e59..a147d66 100644 --- a/src/cli/commands/rm.rs +++ b/src/cli/commands/rm.rs @@ -9,11 +9,9 @@ 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)?; @@ -81,9 +79,7 @@ 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, skip_prompts) - { + if let Ok(Some(suggestion)) = prompt_typo_suggestion(input, &all_slugs) { log::info!("Using suggested project: {suggestion}"); resolved_inputs.push(suggestion); } else { @@ -115,13 +111,13 @@ pub async fn execute( // Ask for confirmation unless --yes flag is provided or --all with no // projects - if !skip_prompts { + if !args.yes { 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, skip_prompts)? { + if !prompt_yes_no("Do you want to continue?", false)? { println!("Removal cancelled."); return Ok(()); } diff --git a/src/cli/commands/status.rs b/src/cli/commands/status.rs index 1e157a8..6b50ed6 100644 --- a/src/cli/commands/status.rs +++ b/src/cli/commands/status.rs @@ -13,7 +13,6 @@ use crate::{ pub async fn execute( parallel: bool, - skip_prompts: bool, lockfile_path: &Path, config_path: &Path, ) -> Result<()> { @@ -78,15 +77,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, skip_prompts)? { + if crate::ui_utils::prompt_yes_no("Update now?", false)? { // 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, ) @@ -381,6 +380,17 @@ 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 e806cfc..c5404cf 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -1,6 +1,7 @@ use std::{ collections::{HashMap, HashSet}, fs, + io::{self, Write}, path::{Path, PathBuf}, }; @@ -21,7 +22,6 @@ enum SyncChange { pub async fn execute( args: SyncArgs, - global_yes: bool, lockfile_path: &Path, config_path: &Path, ) -> Result<()> { @@ -66,11 +66,7 @@ pub async fn execute( for (file_path, _) in &additions { spinner .set_message(format!("Processing addition: {}", file_path.display())); - if crate::ui_utils::prompt_yes_no( - &format!("Add {} to lockfile?", file_path.display()), - false, - global_yes, - )? { + if prompt_user(&format!("Add {} to lockfile?", file_path.display()))? { add_file_to_lockfile(&mut lockfile, file_path, &config).await?; } } @@ -91,11 +87,7 @@ pub async fn execute( .or(project.pakku_id.as_deref()) .unwrap_or("unknown"); spinner.set_message(format!("Processing removal: {name}")); - if crate::ui_utils::prompt_yes_no( - &format!("Remove {name} from lockfile?"), - false, - global_yes, - )? { + if prompt_user(&format!("Remove {name} from lockfile?"))? { lockfile .remove_project(pakku_id) .ok_or_else(|| PakkerError::ProjectNotFound(pakku_id.clone()))?; @@ -182,7 +174,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 @@ -194,7 +186,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(()); @@ -210,3 +202,15 @@ 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 785219b..f33caf2 100644 --- a/src/cli/commands/update.rs +++ b/src/cli/commands/update.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::{collections::HashMap, path::Path}; use indicatif::{ProgressBar, ProgressStyle}; @@ -6,16 +6,15 @@ 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(".")); @@ -24,7 +23,15 @@ pub async fn execute( let _config = Config::load(config_dir)?; // Create platforms - let platforms = super::add::create_all_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); + } // Collect all known project identifiers for typo suggestions let all_slugs: Vec = lockfile @@ -56,8 +63,7 @@ pub async fn execute( indices.push(idx); } else { // Try typo suggestion - if let Ok(Some(suggestion)) = - prompt_typo_suggestion(input, &all_slugs, skip_prompts) + if let Ok(Some(suggestion)) = prompt_typo_suggestion(input, &all_slugs) && let Some((idx, _)) = lockfile .projects .iter() @@ -153,18 +159,17 @@ pub async fn execute( } else { // Interactive confirmation and version selection if not using --yes // flag - let mut should_update = skip_prompts || args.all; + let mut should_update = args.yes || args.all; let mut selected_idx: Option = None; - if !skip_prompts && !args.all { + if !args.yes && !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, skip_prompts).unwrap_or(false); + should_update = prompt_yes_no(&prompt_msg, true).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 e116088..0716df7 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -171,7 +171,6 @@ 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()); } @@ -221,7 +220,6 @@ 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 bfdf040..582a702 100644 --- a/src/export/rules.rs +++ b/src/export/rules.rs @@ -1232,7 +1232,6 @@ 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"), @@ -1363,7 +1362,6 @@ 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"); @@ -1400,7 +1398,6 @@ mod tests { projects: None, export_profiles: None, export_server_side_projects_to_client: None, - file_count_preference: None, }; assert_eq!( @@ -1465,7 +1462,6 @@ 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); @@ -1496,7 +1492,6 @@ 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 a322b4c..95f0aed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,31 +18,12 @@ mod resolver; mod ui_utils; mod utils; -use std::{env, path::PathBuf}; +use std::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(); @@ -62,41 +43,19 @@ async fn main() -> Result<(), PakkerError> { .format_module_path(false) .init(); - // Search for pakker-lock.json in current directory and parent directories - let working_dir = - find_working_directory().unwrap_or_else(|| PathBuf::from(".")); + let working_dir = 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, - global_yes, - &lockfile_path, - &config_path, - ) - .await + cli::commands::init::execute(args, &lockfile_path, &config_path).await }, Commands::Import(args) => { - cli::commands::import::execute( - args, - global_yes, - &lockfile_path, - &config_path, - ) - .await + cli::commands::import::execute(args, &lockfile_path, &config_path).await }, Commands::Add(args) => { - cli::commands::add::execute( - args, - global_yes, - &lockfile_path, - &config_path, - ) - .await + cli::commands::add::execute(args, &lockfile_path, &config_path).await }, Commands::AddPrj(args) => { cli::commands::add_prj::execute( @@ -111,24 +70,17 @@ async fn main() -> Result<(), PakkerError> { args.aliases, args.export, args.no_deps, - global_yes, + args.yes, &lockfile_path, &config_path, ) .await }, Commands::Rm(args) => { - cli::commands::rm::execute(args, global_yes, &lockfile_path, &config_path) - .await + cli::commands::rm::execute(args, &lockfile_path, &config_path).await }, Commands::Update(args) => { - cli::commands::update::execute( - args, - global_yes, - &lockfile_path, - &config_path, - ) - .await + cli::commands::update::execute(args, &lockfile_path, &config_path).await }, Commands::Ls(args) => cli::commands::ls::execute(args, &lockfile_path), Commands::Set(args) => { @@ -143,13 +95,7 @@ async fn main() -> Result<(), PakkerError> { cli::commands::fetch::execute(args, &lockfile_path, &config_path).await }, Commands::Sync(args) => { - cli::commands::sync::execute( - args, - global_yes, - &lockfile_path, - &config_path, - ) - .await + cli::commands::sync::execute(args, &lockfile_path, &config_path).await }, Commands::Export(args) => { cli::commands::export::execute(args, &lockfile_path, &config_path).await @@ -161,7 +107,6 @@ 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 c65a588..83172ae 100644 --- a/src/model/config.rs +++ b/src/model/config.rs @@ -64,9 +64,6 @@ 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 { @@ -83,7 +80,6 @@ impl Default for Config { projects: Some(HashMap::new()), export_profiles: None, export_server_side_projects_to_client: None, - file_count_preference: None, } } } @@ -149,7 +145,6 @@ 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())), @@ -208,7 +203,6 @@ 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"); @@ -230,7 +224,6 @@ 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 dc10595..cdb3a97 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.get_project("test-id"); + let found = lockfile.find_project("test-id"); assert!(found.is_some()); assert_eq!(found.unwrap().pakku_id, Some("test-id".to_string())); - let not_found = lockfile.get_project("nonexistent"); + let not_found = lockfile.find_project("nonexistent"); assert!(not_found.is_none()); } @@ -604,6 +604,10 @@ 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 f4da4aa..2f0620c 100644 --- a/src/model/project.rs +++ b/src/model/project.rs @@ -246,7 +246,6 @@ impl Project { &mut self, mc_versions: &[String], loaders: &[String], - file_count: Option, ) -> crate::error::Result<()> { // Filter compatible files let compatible_files: Vec<_> = self @@ -270,9 +269,10 @@ impl Project { .then_with(|| b.date_published.cmp(&a.date_published)) }); - // 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(); + // Keep only the best file + if let Some(best_file) = sorted_files.first() { + self.files = vec![(*best_file).clone()]; + } 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, None); + let result = project.select_file(&lockfile_mc, &lockfile_loaders); assert!(result.is_ok()); } diff --git a/src/ui_utils.rs b/src/ui_utils.rs index f293256..62589b5 100644 --- a/src/ui_utils.rs +++ b/src/ui_utils.rs @@ -2,7 +2,7 @@ use std::io; -use dialoguer::{Confirm, Input, Select, theme::ColorfulTheme}; +use dialoguer::{Confirm, Input, MultiSelect, Select, theme::ColorfulTheme}; /// Creates a terminal hyperlink using OSC 8 escape sequence /// Format: \x1b]8;;\x1b\\\x1b]8;;\x1b\\ @@ -12,16 +12,7 @@ pub fn hyperlink(url: &str, text: &str) -> String { /// Prompts user with a yes/no question /// Returns true for yes, false for no -/// 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); - } - +pub fn prompt_yes_no(question: &str, default: bool) -> io::Result { Confirm::with_theme(&ColorfulTheme::default()) .with_prompt(question) .default(default) @@ -31,6 +22,7 @@ pub fn prompt_yes_no( /// 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) @@ -40,12 +32,28 @@ 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}") } @@ -110,22 +118,16 @@ 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, - skip_prompts, - )? + && prompt_yes_no(&format!("Did you mean '{first_suggestion}'?"), true)? { return Ok(Some((*first_suggestion).to_string())); } @@ -162,14 +164,7 @@ 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. -/// 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); - } - +pub fn prompt_curseforge_api_key() -> io::Result> { use dialoguer::Password; println!(); @@ -177,7 +172,7 @@ pub fn prompt_curseforge_api_key( 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, false)? { + if !prompt_yes_no("Would you like to enter your API key now?", true)? { return Ok(None); } diff --git a/src/utils/mod.rs b/src/utils.rs similarity index 84% rename from src/utils/mod.rs rename to src/utils.rs index 86947cb..582c559 100644 --- a/src/utils/mod.rs +++ b/src/utils.rs @@ -1,5 +1,6 @@ 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/hash.rs b/src/utils/hash.rs index d440b0e..88abdde 100644 --- a/src/utils/hash.rs +++ b/src/utils/hash.rs @@ -10,6 +10,58 @@ 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)?; @@ -115,6 +167,31 @@ 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/prompt.rs b/src/utils/prompt.rs new file mode 100644 index 0000000..aa6c1dc --- /dev/null +++ b/src/utils/prompt.rs @@ -0,0 +1,56 @@ +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) +}