Compare commits
7 commits
344dc0c7e7
...
dc1ceba1a4
| Author | SHA1 | Date | |
|---|---|---|---|
|
dc1ceba1a4 |
|||
|
369fd9b352 |
|||
|
0288be07f9 |
|||
|
e47690a858 |
|||
|
bb562b542d |
|||
|
0a15e0b1f3 |
|||
|
b72c424ebb |
21 changed files with 186 additions and 267 deletions
28
src/cli.rs
28
src/cli.rs
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
"Would you like to set up CurseForge API key now?",
|
||||||
|
true,
|
||||||
|
skip_prompts,
|
||||||
|
)
|
||||||
.map_err(|e| PakkerError::InvalidInput(e.to_string()))?
|
.map_err(|e| PakkerError::InvalidInput(e.to_string()))?
|
||||||
&& let Ok(Some(api_key)) = prompt_curseforge_api_key()
|
&& 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(
|
||||||
|
|
|
||||||
|
|
@ -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(());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
73
src/main.rs
73
src/main.rs
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue