Compare commits

...

7 commits

Author SHA1 Message Date
dc1ceba1a4
utils: reorganize module structure
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I5b51e349ea67e27170e3a3ebe6b1d3fe6a6a6964
2026-03-03 23:33:14 +03:00
369fd9b352
cli/commands: update all commands to use global_yes parameter
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4d95a425f2bed75aed1b5233adf1a3646a6a6964
2026-03-03 23:33:13 +03:00
0288be07f9
model: add file_count_preference for multi-file selection support
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ia27c829dbcc21a7fcfc8e6f67f9e33276a6a6964
2026-03-03 23:33:12 +03:00
e47690a858
model/lockfile: update tests to use get_project
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I2163215bc069431e3d6d53c9c14dd15c6a6a6964
2026-03-03 23:33:11 +03:00
bb562b542d
cli/commands: use create_all_platforms to reduce duplication in update cmd
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I00d3029de7c13a57cefb1b6eaae9f1606a6a6964
2026-03-03 23:33:10 +03:00
0a15e0b1f3
treewide: remove dead code
Also deletes some dead_code annotations from functions that are
*actually used*.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ic815cacc93c464078ead1674e7523d8b6a6a6964
2026-03-03 23:33:09 +03:00
b72c424ebb
cli: fix global -y flag conflicts in add-prj` and sync commands
`AddPrjArgs` had a local `-y` flag that conflicted with the global flag,
causing runtime panics. Removed the local field and updated callers to
use `global_yes` consistently.

The sync command now respects the global `-y` flag by accepting, you
guessed it, the `global_yes` parameter.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I7b7c42fabbca0e363bd18a1d8b6b3bb76a6a6964
2026-03-03 23:33:08 +03:00
21 changed files with 186 additions and 267 deletions

View file

@ -15,6 +15,10 @@ pub struct Cli {
#[clap(short, long, action = clap::ArgAction::Count)] #[clap(short, long, action = clap::ArgAction::Count)]
pub verbose: u8, pub verbose: u8,
/// Skip all confirmation prompts (assume yes)
#[clap(short, long, global = true)]
pub yes: bool,
#[clap(subcommand)] #[clap(subcommand)]
pub command: Commands, pub command: Commands,
} }
@ -107,10 +111,6 @@ pub struct InitArgs {
/// Mod loaders (format: name=version, can be specified multiple times) /// Mod loaders (format: name=version, can be specified multiple times)
#[clap(short, long = "loaders", value_delimiter = ',')] #[clap(short, long = "loaders", value_delimiter = ',')]
pub loaders: Option<Vec<String>>, pub loaders: Option<Vec<String>>,
/// Skip interactive prompts (use defaults)
#[clap(short, long)]
pub yes: bool,
} }
#[derive(Args)] #[derive(Args)]
@ -121,10 +121,6 @@ pub struct ImportArgs {
/// Resolve dependencies /// Resolve dependencies
#[clap(short = 'D', long = "deps")] #[clap(short = 'D', long = "deps")]
pub deps: bool, pub deps: bool,
/// Skip confirmation prompts
#[clap(short, long)]
pub yes: bool,
} }
#[derive(Args)] #[derive(Args)]
@ -144,10 +140,6 @@ pub struct AddArgs {
/// Update if already exists /// Update if already exists
#[clap(short, long)] #[clap(short, long)]
pub update: bool, pub update: bool,
/// Skip confirmation prompts
#[clap(short, long)]
pub yes: bool,
} }
#[derive(Args)] #[derive(Args)]
@ -195,10 +187,6 @@ pub struct AddPrjArgs {
/// Skip resolving dependencies /// Skip resolving dependencies
#[clap(short = 'D', long = "no-deps")] #[clap(short = 'D', long = "no-deps")]
pub no_deps: bool, pub no_deps: bool,
/// Skip confirmation prompts
#[clap(short, long)]
pub yes: bool,
} }
#[derive(Args)] #[derive(Args)]
@ -211,10 +199,6 @@ pub struct RmArgs {
#[clap(short = 'a', long)] #[clap(short = 'a', long)]
pub all: bool, pub all: bool,
/// Skip confirmation prompt
#[clap(short, long)]
pub yes: bool,
/// Skip removing dependent projects /// Skip removing dependent projects
#[clap(short = 'D', long = "no-deps")] #[clap(short = 'D', long = "no-deps")]
pub no_deps: bool, pub no_deps: bool,
@ -229,10 +213,6 @@ pub struct UpdateArgs {
/// Update all projects /// Update all projects
#[arg(short, long)] #[arg(short, long)]
pub all: bool, pub all: bool,
/// Skip confirmation prompts
#[arg(short, long)]
pub yes: bool,
} }
#[derive(Args)] #[derive(Args)]

View file

@ -57,9 +57,11 @@ use crate::{cli::AddArgs, model::fork::LocalConfig};
pub async fn execute( pub async fn execute(
args: AddArgs, args: AddArgs,
global_yes: bool,
lockfile_path: &Path, lockfile_path: &Path,
config_path: &Path, config_path: &Path,
) -> Result<()> { ) -> Result<()> {
let skip_prompts = global_yes;
log::info!("Adding projects: {:?}", args.inputs); log::info!("Adding projects: {:?}", args.inputs);
// Load lockfile // Load lockfile
@ -187,9 +189,9 @@ pub async fn execute(
} }
// Prompt for confirmation unless --yes flag is set // Prompt for confirmation unless --yes flag is set
if !args.yes { if !skip_prompts {
let prompt_msg = format!("Add project '{}'?", project.get_name()); 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()); log::info!("Skipping project: {}", project.get_name());
continue; continue;
} }
@ -213,13 +215,14 @@ pub async fn execute(
&& !all_new_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 // Prompt user for confirmation unless --yes flag is set
if !args.yes { if !skip_prompts {
let prompt_msg = format!( let prompt_msg = format!(
"Add dependency '{}' required by '{}'?", "Add dependency '{}' required by '{}'?",
dep.get_name(), dep.get_name(),
project.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()); log::info!("Skipping dependency: {}", dep.get_name());
continue; continue;
} }

View file

@ -232,7 +232,7 @@ pub async fn execute(
"Project '{existing_name}' already exists. Replace with \ "Project '{existing_name}' already exists. Replace with \
'{project_name}'?" '{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"); log::info!("Operation cancelled by user");
return Ok(()); return Ok(());
} }
@ -244,7 +244,7 @@ pub async fn execute(
} else { } else {
if !yes { if !yes {
let prompt_msg = format!("Add project '{project_name}'?"); 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"); log::info!("Operation cancelled by user");
return Ok(()); return Ok(());
} }
@ -283,7 +283,7 @@ pub async fn execute(
if !yes { if !yes {
let prompt_msg = let prompt_msg =
format!("Add dependency '{dep_name}' required by '{project_name}'?"); 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}"); log::info!("Skipping dependency: {dep_name}");
continue; continue;
} }

View file

@ -32,7 +32,7 @@ pub fn execute(
// Find the project in lockfile to get its pakku_id // Find the project in lockfile to get its pakku_id
// Try multiple lookup strategies: pakku_id first, then slug, then name // Try multiple lookup strategies: pakku_id first, then slug, then name
let found_project = lockfile let found_project = lockfile
.find_project(&project) .get_project(&project)
.or_else(|| { .or_else(|| {
// Try to find by slug on any platform // Try to find by slug on any platform
lockfile lockfile

View file

@ -9,9 +9,11 @@ use crate::{
pub async fn execute( pub async fn execute(
args: ImportArgs, args: ImportArgs,
global_yes: bool,
lockfile_path: &Path, lockfile_path: &Path,
config_path: &Path, config_path: &Path,
) -> Result<()> { ) -> Result<()> {
let skip_prompts = global_yes;
log::info!("Importing modpack from {}", args.file); log::info!("Importing modpack from {}", args.file);
log::info!( log::info!(
"Dependency resolution: {}", "Dependency resolution: {}",
@ -27,7 +29,7 @@ pub async fn execute(
} }
// Check if lockfile or config already exist // 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() { let msg = if lockfile_path.exists() && config_path.exists() {
"Both pakku-lock.json and pakku.json exist. Importing will overwrite \ "Both pakku-lock.json and pakku.json exist. Importing will overwrite \
them. Continue?" them. Continue?"
@ -37,7 +39,7 @@ pub async fn execute(
"pakku.json exists. Importing will overwrite it. Continue?" "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"); log::info!("Import cancelled by user");
return Ok(()); return Ok(());
} }
@ -146,6 +148,7 @@ async fn import_modrinth(
if let Err(e) = project.select_file( if let Err(e) = project.select_file(
&lockfile.mc_versions, &lockfile.mc_versions,
std::slice::from_ref(&loader.0), std::slice::from_ref(&loader.0),
None, // Use default (1 file) during import
) { ) {
log::warn!( log::warn!(
"Failed to select file for {}: {}", "Failed to select file for {}: {}",
@ -185,6 +188,7 @@ async fn import_modrinth(
projects: None, projects: None,
export_profiles: None, export_profiles: None,
export_server_side_projects_to_client: None, export_server_side_projects_to_client: None,
file_count_preference: None,
}; };
// Save files using provided paths // Save files using provided paths
@ -314,6 +318,7 @@ async fn import_curseforge(
if let Err(e) = project.select_file( if let Err(e) = project.select_file(
&lockfile.mc_versions, &lockfile.mc_versions,
&loaders.keys().cloned().collect::<Vec<_>>(), &loaders.keys().cloned().collect::<Vec<_>>(),
None, // Use default (1 file) during import
) { ) {
log::warn!( log::warn!(
"Failed to select file for {}: {}", "Failed to select file for {}: {}",
@ -328,6 +333,7 @@ async fn import_curseforge(
if let Err(e) = project.select_file( if let Err(e) = project.select_file(
&lockfile.mc_versions, &lockfile.mc_versions,
&loaders.keys().cloned().collect::<Vec<_>>(), &loaders.keys().cloned().collect::<Vec<_>>(),
None, // Use default (1 file) during import
) { ) {
log::warn!( log::warn!(
"Failed to select file for {}: {}", "Failed to select file for {}: {}",
@ -368,6 +374,7 @@ async fn import_curseforge(
projects: None, projects: None,
export_profiles: None, export_profiles: None,
export_server_side_projects_to_client: None, export_server_side_projects_to_client: None,
file_count_preference: None,
}; };
// Save files using provided paths // Save files using provided paths

View file

@ -14,9 +14,12 @@ use crate::{
pub async fn execute( pub async fn execute(
args: InitArgs, args: InitArgs,
global_yes: bool,
lockfile_path: &Path, lockfile_path: &Path,
config_path: &Path, config_path: &Path,
) -> Result<(), PakkerError> { ) -> Result<(), PakkerError> {
let skip_prompts = global_yes;
if lockfile_path.exists() { if lockfile_path.exists() {
return Err(PakkerError::AlreadyExists( return Err(PakkerError::AlreadyExists(
"Lock file already exists".into(), "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 // 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 // Get modpack name
let name = if let Some(name) = args.name.clone() { let name = if let Some(name) = args.name.clone() {
@ -137,6 +140,7 @@ pub async fn execute(
projects: None, projects: None,
export_profiles: None, export_profiles: None,
export_server_side_projects_to_client: None, export_server_side_projects_to_client: None,
file_count_preference: None,
}; };
let config_dir = config_path.parent().unwrap_or(Path::new(".")); let config_dir = config_path.parent().unwrap_or(Path::new("."));
@ -164,9 +168,13 @@ pub async fn execute(
if !has_cf_key { if !has_cf_key {
println!(); println!();
if prompt_yes_no("Would you like to set up CurseForge API key now?", true) if prompt_yes_no(
.map_err(|e| PakkerError::InvalidInput(e.to_string()))? "Would you like to set up CurseForge API key now?",
&& let Ok(Some(api_key)) = prompt_curseforge_api_key() 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 // Save to credentials file
let creds_path = std::env::var("HOME").map_or_else( let creds_path = std::env::var("HOME").map_or_else(

View file

@ -9,9 +9,11 @@ use crate::{
pub async fn execute( pub async fn execute(
args: RmArgs, args: RmArgs,
global_yes: bool,
lockfile_path: &Path, lockfile_path: &Path,
_config_path: &Path, _config_path: &Path,
) -> Result<()> { ) -> Result<()> {
let skip_prompts = global_yes;
// Load expects directory path, so get parent directory // Load expects directory path, so get parent directory
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new(".")); let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
let mut lockfile = LockFile::load(lockfile_dir)?; let mut lockfile = LockFile::load(lockfile_dir)?;
@ -79,7 +81,9 @@ pub async fn execute(
resolved_inputs.push(input.clone()); resolved_inputs.push(input.clone());
} else if !args.all { } else if !args.all {
// Try typo suggestion // 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}"); log::info!("Using suggested project: {suggestion}");
resolved_inputs.push(suggestion); resolved_inputs.push(suggestion);
} else { } else {
@ -111,13 +115,13 @@ pub async fn execute(
// Ask for confirmation unless --yes flag is provided or --all with no // Ask for confirmation unless --yes flag is provided or --all with no
// projects // projects
if !args.yes { if !skip_prompts {
println!("The following projects will be removed:"); println!("The following projects will be removed:");
for name in &projects_to_remove { for name in &projects_to_remove {
println!(" - {name}"); 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."); println!("Removal cancelled.");
return Ok(()); return Ok(());
} }

View file

@ -13,6 +13,7 @@ use crate::{
pub async fn execute( pub async fn execute(
parallel: bool, parallel: bool,
skip_prompts: bool,
lockfile_path: &Path, lockfile_path: &Path,
config_path: &Path, config_path: &Path,
) -> Result<()> { ) -> Result<()> {
@ -77,15 +78,15 @@ pub async fn execute(
// Prompt to update if there are updates available // Prompt to update if there are updates available
if !updates.is_empty() { if !updates.is_empty() {
println!(); 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) // Call update command programmatically (update all projects)
let update_args = crate::cli::UpdateArgs { let update_args = crate::cli::UpdateArgs {
inputs: vec![], inputs: vec![],
all: true, all: true,
yes: true, // Auto-yes for status command
}; };
crate::cli::commands::update::execute( crate::cli::commands::update::execute(
update_args, update_args,
true, // Auto-yes for status command
lockfile_path, lockfile_path,
config_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<String> { fn get_api_key(platform: &str) -> Option<String> {
match platform { match platform {
"modrinth" => std::env::var("MODRINTH_TOKEN").ok(), "modrinth" => std::env::var("MODRINTH_TOKEN").ok(),

View file

@ -1,7 +1,6 @@
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
fs, fs,
io::{self, Write},
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
@ -22,6 +21,7 @@ enum SyncChange {
pub async fn execute( pub async fn execute(
args: SyncArgs, args: SyncArgs,
global_yes: bool,
lockfile_path: &Path, lockfile_path: &Path,
config_path: &Path, config_path: &Path,
) -> Result<()> { ) -> Result<()> {
@ -66,7 +66,11 @@ pub async fn execute(
for (file_path, _) in &additions { for (file_path, _) in &additions {
spinner spinner
.set_message(format!("Processing addition: {}", file_path.display())); .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?; add_file_to_lockfile(&mut lockfile, file_path, &config).await?;
} }
} }
@ -87,7 +91,11 @@ pub async fn execute(
.or(project.pakku_id.as_deref()) .or(project.pakku_id.as_deref())
.unwrap_or("unknown"); .unwrap_or("unknown");
spinner.set_message(format!("Processing removal: {name}")); 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 lockfile
.remove_project(pakku_id) .remove_project(pakku_id)
.ok_or_else(|| PakkerError::ProjectNotFound(pakku_id.clone()))?; .ok_or_else(|| PakkerError::ProjectNotFound(pakku_id.clone()))?;
@ -174,7 +182,7 @@ async fn add_file_to_lockfile(
_config: &Config, _config: &Config,
) -> Result<()> { ) -> Result<()> {
// Try to identify the file by hash lookup // Try to identify the file by hash lookup
let _modrinth = ModrinthPlatform::new(); let modrinth = ModrinthPlatform::new();
let curseforge = CurseForgePlatform::new(None); let curseforge = CurseForgePlatform::new(None);
// Compute file hash // Compute file hash
@ -186,7 +194,7 @@ async fn add_file_to_lockfile(
let hash = format!("{:x}", hasher.finalize()); let hash = format!("{:x}", hasher.finalize());
// Try Modrinth first (SHA-1 hash) // 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); lockfile.add_project(project);
println!("✓ Added {} (from Modrinth)", file_path.display()); println!("✓ Added {} (from Modrinth)", file_path.display());
return Ok(()); return Ok(());
@ -202,15 +210,3 @@ async fn add_file_to_lockfile(
println!("⚠ Could not identify {}, skipping", file_path.display()); println!("⚠ Could not identify {}, skipping", file_path.display());
Ok(()) Ok(())
} }
fn prompt_user(message: &str) -> Result<bool> {
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"))
}

View file

@ -1,4 +1,4 @@
use std::{collections::HashMap, path::Path}; use std::path::Path;
use indicatif::{ProgressBar, ProgressStyle}; use indicatif::{ProgressBar, ProgressStyle};
@ -6,15 +6,16 @@ use crate::{
cli::UpdateArgs, cli::UpdateArgs,
error::{MultiError, PakkerError}, error::{MultiError, PakkerError},
model::{Config, LockFile, UpdateStrategy}, model::{Config, LockFile, UpdateStrategy},
platform::create_platform,
ui_utils::{prompt_select, prompt_typo_suggestion, prompt_yes_no}, ui_utils::{prompt_select, prompt_typo_suggestion, prompt_yes_no},
}; };
pub async fn execute( pub async fn execute(
args: UpdateArgs, args: UpdateArgs,
global_yes: bool,
lockfile_path: &Path, lockfile_path: &Path,
config_path: &Path, config_path: &Path,
) -> Result<(), PakkerError> { ) -> Result<(), PakkerError> {
let skip_prompts = global_yes;
// Load expects directory path, so get parent directory // Load expects directory path, so get parent directory
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new(".")); let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
let config_dir = config_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)?; let _config = Config::load(config_dir)?;
// Create platforms // Create platforms
let mut platforms = HashMap::new(); let platforms = super::add::create_all_platforms()?;
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 // Collect all known project identifiers for typo suggestions
let all_slugs: Vec<String> = lockfile let all_slugs: Vec<String> = lockfile
@ -63,7 +56,8 @@ pub async fn execute(
indices.push(idx); indices.push(idx);
} else { } else {
// Try typo suggestion // 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 && let Some((idx, _)) = lockfile
.projects .projects
.iter() .iter()
@ -159,17 +153,18 @@ pub async fn execute(
} else { } else {
// Interactive confirmation and version selection if not using --yes // Interactive confirmation and version selection if not using --yes
// flag // flag
let mut should_update = args.yes || args.all; let mut should_update = skip_prompts || args.all;
let mut selected_idx: Option<usize> = None; let mut selected_idx: Option<usize> = None;
if !args.yes && !args.all { if !skip_prompts && !args.all {
pb.suspend(|| { pb.suspend(|| {
// First, confirm the update // First, confirm the update
let prompt_msg = format!( let prompt_msg = format!(
"Update '{project_name}' from {old_file_name} to \ "Update '{project_name}' from {old_file_name} to \
{new_file_name}?" {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 confirmed and multiple versions available, offer selection
if should_update && updated_project.files.len() > 1 { if should_update && updated_project.files.len() > 1 {

View file

@ -171,6 +171,7 @@ mod tests {
projects: None, projects: None,
export_profiles: None, export_profiles: None,
export_server_side_projects_to_client: None, export_server_side_projects_to_client: None,
file_count_preference: None,
}; };
assert!(config.export_server_side_projects_to_client.is_none()); assert!(config.export_server_side_projects_to_client.is_none());
} }
@ -220,6 +221,7 @@ mod tests {
projects: None, projects: None,
export_profiles: None, export_profiles: None,
export_server_side_projects_to_client: None, export_server_side_projects_to_client: None,
file_count_preference: None,
}; };
let json = serde_json::to_string_pretty(&config).unwrap(); let json = serde_json::to_string_pretty(&config).unwrap();

View file

@ -1232,6 +1232,7 @@ mod tests {
projects: None, projects: None,
export_profiles: None, export_profiles: None,
export_server_side_projects_to_client: None, export_server_side_projects_to_client: None,
file_count_preference: None,
}, },
profile_config, profile_config,
export_path: PathBuf::from("/tmp/export"), export_path: PathBuf::from("/tmp/export"),
@ -1362,6 +1363,7 @@ mod tests {
projects: None, projects: None,
export_profiles: None, export_profiles: None,
export_server_side_projects_to_client: None, export_server_side_projects_to_client: None,
file_count_preference: None,
}; };
assert_eq!(get_project_type_dir(&ProjectType::Mod, &config), "mods"); assert_eq!(get_project_type_dir(&ProjectType::Mod, &config), "mods");
@ -1398,6 +1400,7 @@ mod tests {
projects: None, projects: None,
export_profiles: None, export_profiles: None,
export_server_side_projects_to_client: None, export_server_side_projects_to_client: None,
file_count_preference: None,
}; };
assert_eq!( assert_eq!(
@ -1462,6 +1465,7 @@ mod tests {
projects: None, projects: None,
export_profiles: None, export_profiles: None,
export_server_side_projects_to_client: None, export_server_side_projects_to_client: None,
file_count_preference: None,
}; };
let mut context = create_test_context(None); let mut context = create_test_context(None);
@ -1492,6 +1496,7 @@ mod tests {
projects: None, projects: None,
export_profiles: None, export_profiles: None,
export_server_side_projects_to_client: None, export_server_side_projects_to_client: None,
file_count_preference: None,
}; };
let mut context = create_test_context(None); let mut context = create_test_context(None);

View file

@ -18,12 +18,31 @@ mod resolver;
mod ui_utils; mod ui_utils;
mod utils; mod utils;
use std::path::PathBuf; use std::{env, path::PathBuf};
use clap::Parser; use clap::Parser;
use cli::{Cli, Commands}; use cli::{Cli, Commands};
use error::PakkerError; 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<PathBuf> {
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] #[tokio::main]
async fn main() -> Result<(), PakkerError> { async fn main() -> Result<(), PakkerError> {
let cli = Cli::parse(); let cli = Cli::parse();
@ -43,19 +62,41 @@ async fn main() -> Result<(), PakkerError> {
.format_module_path(false) .format_module_path(false)
.init(); .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 lockfile_path = working_dir.join("pakker-lock.json");
let config_path = working_dir.join("pakker.json"); let config_path = working_dir.join("pakker.json");
let global_yes = cli.yes;
match cli.command { match cli.command {
Commands::Init(args) => { 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) => { 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) => { 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) => { Commands::AddPrj(args) => {
cli::commands::add_prj::execute( cli::commands::add_prj::execute(
@ -70,17 +111,24 @@ async fn main() -> Result<(), PakkerError> {
args.aliases, args.aliases,
args.export, args.export,
args.no_deps, args.no_deps,
args.yes, global_yes,
&lockfile_path, &lockfile_path,
&config_path, &config_path,
) )
.await .await
}, },
Commands::Rm(args) => { 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) => { 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::Ls(args) => cli::commands::ls::execute(args, &lockfile_path),
Commands::Set(args) => { Commands::Set(args) => {
@ -95,7 +143,13 @@ async fn main() -> Result<(), PakkerError> {
cli::commands::fetch::execute(args, &lockfile_path, &config_path).await cli::commands::fetch::execute(args, &lockfile_path, &config_path).await
}, },
Commands::Sync(args) => { 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) => { Commands::Export(args) => {
cli::commands::export::execute(args, &lockfile_path, &config_path).await cli::commands::export::execute(args, &lockfile_path, &config_path).await
@ -107,6 +161,7 @@ async fn main() -> Result<(), PakkerError> {
Commands::Status(args) => { Commands::Status(args) => {
cli::commands::status::execute( cli::commands::status::execute(
args.parallel, args.parallel,
global_yes,
&lockfile_path, &lockfile_path,
&config_path, &config_path,
) )

View file

@ -64,6 +64,9 @@ pub struct Config {
rename = "exportServerSideProjectsToClient" rename = "exportServerSideProjectsToClient"
)] )]
pub export_server_side_projects_to_client: Option<bool>, pub export_server_side_projects_to_client: Option<bool>,
/// Number of files to select per project (defaults to 1)
#[serde(skip_serializing_if = "Option::is_none")]
pub file_count_preference: Option<usize>,
} }
impl Default for Config { impl Default for Config {
@ -80,6 +83,7 @@ impl Default for Config {
projects: Some(HashMap::new()), projects: Some(HashMap::new()),
export_profiles: None, export_profiles: None,
export_server_side_projects_to_client: None, export_server_side_projects_to_client: None,
file_count_preference: None,
} }
} }
} }
@ -145,6 +149,7 @@ impl Config {
projects: Some(pakku.projects), projects: Some(pakku.projects),
export_profiles: None, export_profiles: None,
export_server_side_projects_to_client: None, export_server_side_projects_to_client: None,
file_count_preference: None,
}) })
}, },
Err(e) => Err(PakkerError::InvalidConfigFile(e.to_string())), Err(e) => Err(PakkerError::InvalidConfigFile(e.to_string())),
@ -203,6 +208,7 @@ mod tests {
projects: None, projects: None,
export_profiles: None, export_profiles: None,
export_server_side_projects_to_client: None, export_server_side_projects_to_client: None,
file_count_preference: None,
}; };
assert_eq!(config.name, "test-pack"); assert_eq!(config.name, "test-pack");
assert_eq!(config.version, "1.0.0"); assert_eq!(config.version, "1.0.0");
@ -224,6 +230,7 @@ mod tests {
projects: None, projects: None,
export_profiles: None, export_profiles: None,
export_server_side_projects_to_client: None, export_server_side_projects_to_client: None,
file_count_preference: None,
}; };
config.description = Some("A test modpack".to_string()); config.description = Some("A test modpack".to_string());
config.author = Some("Test Author".to_string()); config.author = Some("Test Author".to_string());

View file

@ -76,11 +76,11 @@ mod tests {
lockfile.add_project(create_test_project("test-id", "test-slug")); 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!(found.is_some());
assert_eq!(found.unwrap().pakku_id, Some("test-id".to_string())); 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()); 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> { pub fn find_project_mut(&mut self, pakku_id: &str) -> Option<&mut Project> {
self self
.projects .projects

View file

@ -246,6 +246,7 @@ impl Project {
&mut self, &mut self,
mc_versions: &[String], mc_versions: &[String],
loaders: &[String], loaders: &[String],
file_count: Option<usize>,
) -> crate::error::Result<()> { ) -> crate::error::Result<()> {
// Filter compatible files // Filter compatible files
let compatible_files: Vec<_> = self let compatible_files: Vec<_> = self
@ -269,10 +270,9 @@ impl Project {
.then_with(|| b.date_published.cmp(&a.date_published)) .then_with(|| b.date_published.cmp(&a.date_published))
}); });
// Keep only the best file // Keep the specified number of files (default to 1 if not specified)
if let Some(best_file) = sorted_files.first() { let count = file_count.unwrap_or(1);
self.files = vec![(*best_file).clone()]; self.files = sorted_files.into_iter().take(count).cloned().collect();
}
Ok(()) Ok(())
} }
@ -531,7 +531,7 @@ mod tests {
let lockfile_mc = vec!["1.20.1".to_string()]; let lockfile_mc = vec!["1.20.1".to_string()];
let lockfile_loaders = vec!["fabric".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()); assert!(result.is_ok());
} }

View file

@ -2,7 +2,7 @@
use std::io; 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 /// Creates a terminal hyperlink using OSC 8 escape sequence
/// Format: \x1b]8;;<URL>\x1b\\<TEXT>\x1b]8;;\x1b\\ /// Format: \x1b]8;;<URL>\x1b\\<TEXT>\x1b]8;;\x1b\\
@ -12,7 +12,16 @@ pub fn hyperlink(url: &str, text: &str) -> String {
/// Prompts user with a yes/no question /// Prompts user with a yes/no question
/// Returns true for yes, false for no /// Returns true for yes, false for no
pub fn prompt_yes_no(question: &str, default: bool) -> io::Result<bool> { /// 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<bool> {
if skip_prompts {
return Ok(default);
}
Confirm::with_theme(&ColorfulTheme::default()) Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(question) .with_prompt(question)
.default(default) .default(default)
@ -22,7 +31,6 @@ pub fn prompt_yes_no(question: &str, default: bool) -> io::Result<bool> {
/// Prompts user to select from a list of options /// Prompts user to select from a list of options
/// Returns the index of the selected option /// Returns the index of the selected option
#[allow(dead_code)]
pub fn prompt_select(question: &str, options: &[&str]) -> io::Result<usize> { pub fn prompt_select(question: &str, options: &[&str]) -> io::Result<usize> {
Select::with_theme(&ColorfulTheme::default()) Select::with_theme(&ColorfulTheme::default())
.with_prompt(question) .with_prompt(question)
@ -32,28 +40,12 @@ pub fn prompt_select(question: &str, options: &[&str]) -> io::Result<usize> {
.map_err(io::Error::other) .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<Vec<usize>> {
MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt(question)
.items(options)
.interact()
.map_err(io::Error::other)
}
/// Creates a formatted project URL for Modrinth /// Creates a formatted project URL for Modrinth
#[allow(dead_code)]
pub fn modrinth_project_url(slug: &str) -> String { pub fn modrinth_project_url(slug: &str) -> String {
format!("https://modrinth.com/mod/{slug}") format!("https://modrinth.com/mod/{slug}")
} }
/// Creates a formatted project URL for `CurseForge` /// Creates a formatted project URL for `CurseForge`
#[allow(dead_code)]
pub fn curseforge_project_url(project_id: &str) -> String { pub fn curseforge_project_url(project_id: &str) -> String {
format!("https://www.curseforge.com/minecraft/mc-mods/{project_id}") 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. /// Prompt user if they meant a similar project name.
/// Returns `Some(suggested_name)` if user confirms, None otherwise. /// Returns `Some(suggested_name)` if user confirms, None otherwise.
/// If `skip_prompts` is true, automatically accepts the first suggestion.
pub fn prompt_typo_suggestion( pub fn prompt_typo_suggestion(
input: &str, input: &str,
candidates: &[String], candidates: &[String],
skip_prompts: bool,
) -> io::Result<Option<String>> { ) -> io::Result<Option<String>> {
// Use a max distance based on input length for reasonable suggestions // Use a max distance based on input length for reasonable suggestions
let max_distance = (input.len() / 2).clamp(2, 4); let max_distance = (input.len() / 2).clamp(2, 4);
let suggestions = suggest_similar(input, candidates, max_distance); let suggestions = suggest_similar(input, candidates, max_distance);
if let Some(first_suggestion) = suggestions.first() 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())); return Ok(Some((*first_suggestion).to_string()));
} }
@ -164,7 +162,14 @@ pub fn prompt_input_optional(prompt: &str) -> io::Result<Option<String>> {
/// Prompt for `CurseForge` API key when authentication fails. /// Prompt for `CurseForge` API key when authentication fails.
/// Returns the API key if provided, None if cancelled. /// Returns the API key if provided, None if cancelled.
pub fn prompt_curseforge_api_key() -> io::Result<Option<String>> { /// If `skip_prompts` is true, returns None immediately.
pub fn prompt_curseforge_api_key(
skip_prompts: bool,
) -> io::Result<Option<String>> {
if skip_prompts {
return Ok(None);
}
use dialoguer::Password; use dialoguer::Password;
println!(); println!();
@ -172,7 +177,7 @@ pub fn prompt_curseforge_api_key() -> io::Result<Option<String>> {
println!("Get your API key from: https://console.curseforge.com/"); println!("Get your API key from: https://console.curseforge.com/");
println!(); 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); return Ok(None);
} }

View file

@ -10,58 +10,6 @@ use sha2::{Sha256, Sha512};
use crate::error::{PakkerError, Result}; 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 /// Compute SHA1 hash of a file
pub fn compute_sha1<P: AsRef<Path>>(path: P) -> Result<String> { pub fn compute_sha1<P: AsRef<Path>>(path: P) -> Result<String> {
let file = File::open(path)?; let file = File::open(path)?;
@ -167,31 +115,6 @@ pub fn verify_hash<P: AsRef<Path>>(
mod tests { mod tests {
use super::*; 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] #[test]
fn test_sha256_bytes_deterministic() { fn test_sha256_bytes_deterministic() {
let data = b"test data"; let data = b"test data";

View file

@ -1,6 +1,5 @@
pub mod hash; pub mod hash;
pub mod id; pub mod id;
pub mod prompt;
pub use hash::verify_hash; pub use hash::verify_hash;
pub use id::generate_pakku_id; pub use id::generate_pakku_id;

View file

@ -1,56 +0,0 @@
use std::io::{self, Write};
use crate::error::Result;
#[allow(dead_code)]
pub fn prompt_user(message: &str) -> Result<String> {
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<usize> {
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::<usize>()
&& 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<bool> {
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<bool> {
prompt_confirm(message)
}