initial commit

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ife1391ed23a1e7f388b1b5eca90b9ea76a6a6964
This commit is contained in:
raf 2026-01-29 19:36:25 +03:00
commit ef28bdaeb4
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
63 changed files with 17292 additions and 0 deletions

216
src/cli/commands/sync.rs Normal file
View file

@ -0,0 +1,216 @@
use std::{
collections::{HashMap, HashSet},
fs,
io::{self, Write},
path::{Path, PathBuf},
};
use indicatif::{ProgressBar, ProgressStyle};
use crate::{
cli::SyncArgs,
error::{PakkerError, Result},
fetch::Fetcher,
model::{Config, LockFile},
platform::{CurseForgePlatform, ModrinthPlatform, PlatformClient},
};
enum SyncChange {
Addition(PathBuf, String), // (file_path, project_name)
Removal(String), // project_pakku_id
}
pub async fn execute(
args: SyncArgs,
lockfile_path: &Path,
config_path: &Path,
) -> Result<()> {
log::info!("Synchronizing with lockfile");
let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
let config_dir = config_path.parent().unwrap_or(Path::new("."));
let mut lockfile = LockFile::load(lockfile_dir)?;
let config = Config::load(config_dir)?;
// Detect changes
let changes = detect_changes(&lockfile, &config)?;
if changes.is_empty() {
println!("✓ Everything is in sync");
return Ok(());
}
// Filter changes based on flags
let mut additions = Vec::new();
let mut removals = Vec::new();
for change in changes {
match change {
SyncChange::Addition(path, name) => additions.push((path, name)),
SyncChange::Removal(id) => removals.push(id),
}
}
// Apply filters
let no_filter = !args.additions && !args.removals;
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} {msg}")
.unwrap(),
);
if no_filter || args.additions {
for (file_path, _) in &additions {
spinner
.set_message(format!("Processing addition: {}", file_path.display()));
if prompt_user(&format!("Add {} to lockfile?", file_path.display()))? {
add_file_to_lockfile(&mut lockfile, file_path, &config).await?;
}
}
}
if no_filter || args.removals {
for pakku_id in &removals {
if let Some(project) = lockfile
.projects
.iter()
.find(|p| p.pakku_id.as_ref() == Some(pakku_id))
{
let name = project
.name
.values()
.next()
.map(std::string::String::as_str)
.or(project.pakku_id.as_deref())
.unwrap_or("unknown");
spinner.set_message(format!("Processing removal: {name}"));
if prompt_user(&format!("Remove {name} from lockfile?"))? {
lockfile
.remove_project(pakku_id)
.ok_or_else(|| PakkerError::ProjectNotFound(pakku_id.clone()))?;
}
}
}
}
spinner.finish_and_clear();
// Save changes
lockfile.save(lockfile_dir)?;
// Fetch missing files
let fetcher = Fetcher::new(".");
fetcher.sync(&lockfile, &config).await?;
println!("✓ Sync complete");
Ok(())
}
fn detect_changes(
lockfile: &LockFile,
config: &Config,
) -> Result<Vec<SyncChange>> {
let mut changes = Vec::new();
// Get paths for each project type
let paths = config.paths.clone();
let mods_path = paths
.get("mods")
.map_or("mods", std::string::String::as_str);
// Build map of lockfile projects by file path
let mut lockfile_files: HashMap<PathBuf, String> = HashMap::new();
for project in &lockfile.projects {
for file in &project.files {
let file_path = PathBuf::from(mods_path).join(&file.file_name);
if let Some(ref pakku_id) = project.pakku_id {
lockfile_files.insert(file_path, pakku_id.clone());
}
}
}
// Scan filesystem for additions
if let Ok(entries) = fs::read_dir(mods_path) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file()
&& let Some(ext) = path.extension()
&& ext == "jar"
&& !lockfile_files.contains_key(&path)
{
let name = path.file_name().unwrap().to_string_lossy().to_string();
changes.push(SyncChange::Addition(path, name));
}
}
}
// Check for removals (projects in lockfile but files missing)
let filesystem_files: HashSet<_> =
if let Ok(entries) = fs::read_dir(mods_path) {
entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_file())
.collect()
} else {
HashSet::new()
};
for (lockfile_path, pakku_id) in &lockfile_files {
if !filesystem_files.contains(lockfile_path) {
changes.push(SyncChange::Removal(pakku_id.clone()));
}
}
Ok(changes)
}
async fn add_file_to_lockfile(
lockfile: &mut LockFile,
file_path: &Path,
_config: &Config,
) -> Result<()> {
// Try to identify the file by hash lookup
let _modrinth = ModrinthPlatform::new();
let curseforge = CurseForgePlatform::new(None);
// Compute file hash
let file_data = fs::read(file_path)?;
// Compute SHA-1 hash from file bytes
use sha1::Digest;
let mut hasher = sha1::Sha1::new();
hasher.update(&file_data);
let hash = format!("{:x}", hasher.finalize());
// Try Modrinth first (SHA-1 hash)
if let Ok(Some(project)) = _modrinth.lookup_by_hash(&hash).await {
lockfile.add_project(project);
println!("✓ Added {} (from Modrinth)", file_path.display());
return Ok(());
}
// Try CurseForge (Murmur2 hash computed from file)
if let Ok(Some(project)) = curseforge.lookup_by_hash(&hash).await {
lockfile.add_project(project);
println!("✓ Added {} (from CurseForge)", file_path.display());
return Ok(());
}
println!("⚠ Could not identify {}, skipping", file_path.display());
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"))
}