1#![warn(clippy::pedantic)]
26
27use std::fmt::Write as _;
34use std::io::Write as _;
35
36pub mod config;
37pub mod crates_io;
38pub mod modrinth;
39
40use crate::config::{BranchConfig, BranchFiles, BranchFilesProject, Modpack, ProjectSettings};
41use crate::modrinth::{Env, File, FileResult, SideSupport, VersionDependency};
42use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
43use reqwest_retry::RetryTransientMiddleware;
44use reqwest_retry::policies::ExponentialBackoff;
45use std::collections::HashMap;
46use std::fmt::{Display, Formatter};
47use std::fs;
48use std::fs::OpenOptions;
49use std::path::Path;
50use std::sync::OnceLock;
51use std::time::Duration;
52
53pub const TARGET_DIRECTORY: &str = "target";
57
58static CLIENT: OnceLock<ClientWithMiddleware> = OnceLock::new();
59const USER_AGENT: &str = concat!(
60 "Thijzert123",
61 "/",
62 "packrinth",
63 "/",
64 env!("CARGO_PKG_VERSION")
65);
66
67fn request_text<T: ToString + ?Sized>(full_url: &T) -> PackrinthResult<String> {
68 let client = CLIENT.get_or_init(|| {
69 let retry_policy = ExponentialBackoff::builder()
70 .build_with_total_retry_duration(Duration::from_secs(60 * 2));
71 ClientBuilder::new(
72 reqwest::Client::builder()
73 .user_agent(USER_AGENT)
74 .build()
75 .expect("Failed to build request client"),
76 )
77 .with(RetryTransientMiddleware::new_with_policy(retry_policy))
78 .build()
79 });
80
81 let runtime = tokio::runtime::Runtime::new().expect("Failed to create runtime");
82 let response = runtime
83 .block_on(client.get(full_url.to_string()).send())
84 .expect("Failed to get response");
85 match runtime.block_on(response.text()) {
86 Ok(text) => Ok(text),
87 Err(error) => Err(PackrinthError::RequestFailed {
88 url: full_url.to_string(),
89 error_message: error.to_string(),
90 }),
91 }
92}
93
94pub const MRPACK_INDEX_FILE_NAME: &str = "modrinth.index.json";
100
101#[allow(clippy::struct_excessive_bools)]
109#[derive(Debug, PartialEq, Eq)]
110pub struct ProjectUpdater<'a> {
111 pub branch_name: &'a str,
112 pub branch_config: &'a BranchConfig,
113 pub branch_files: &'a mut BranchFiles,
114 pub slug_project_id: &'a str,
115 pub project_settings: &'a ProjectSettings,
116 pub require_all: bool,
117 pub no_beta: bool,
118 pub no_alpha: bool,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Hash)]
123pub enum ProjectUpdateResult {
124 Added(Vec<VersionDependency>),
127
128 Skipped,
130
131 NotFound,
134
135 Failed(PackrinthError),
137}
138
139impl ProjectUpdater<'_> {
140 pub fn update_project(&mut self) -> ProjectUpdateResult {
142 match File::from_project(
143 self.branch_name,
144 self.branch_config,
145 self.slug_project_id,
146 self.project_settings,
147 self.no_beta,
148 self.no_alpha,
149 ) {
150 FileResult::Ok {
151 mut file,
152 dependencies,
153 project_id,
154 } => {
155 self.branch_files.projects.push(BranchFilesProject {
156 name: file.project_name.clone(),
157 id: Some(project_id),
158 });
159
160 if self.require_all {
161 file.env = Some(Env {
162 client: SideSupport::Required,
163 server: SideSupport::Required,
164 });
165 }
166
167 self.branch_files.files.push(file);
168 ProjectUpdateResult::Added(dependencies)
169 }
170 FileResult::Skipped => ProjectUpdateResult::Skipped,
171 FileResult::NotFound => ProjectUpdateResult::NotFound,
172 FileResult::Err(error) => ProjectUpdateResult::Failed(error),
173 }
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct ProjectTable {
183 pub column_names: Vec<String>,
186
187 pub project_map: HashMap<BranchFilesProject, HashMap<String, Option<()>>>,
193}
194
195impl Display for ProjectTable {
196 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
201 writeln!(f, "|{}|", self.column_names.join("|"))?;
203
204 write!(f, "|:--|")?;
206 for _ in 1..self.column_names.len() {
208 write!(f, ":-:|")?;
209 }
210 writeln!(f)?;
211
212 let mut sorted_project_map: Vec<_> = self.project_map.iter().collect();
213 sorted_project_map.sort_by(|a, b| a.0.name.cmp(&b.0.name));
215
216 let mut iter = sorted_project_map.iter().peekable();
217 while let Some(project) = iter.next() {
218 if let Some(id) = &project.0.id {
219 let mut project_url = "https://modrinth.com/project/".to_string();
221 project_url.push_str(id);
222 write!(f, "|[{}]({})|", project.0.name, project_url)?;
223 } else {
224 write!(f, "|{}|", project.0.name)?;
225 }
226
227 let mut sorted_branch_map: Vec<_> = project.1.iter().collect();
228 sorted_branch_map.sort_by(|a, b| a.0.cmp(b.0));
230
231 for branch in sorted_branch_map {
232 let icon = match branch.1 {
233 Some(()) => "✅",
234 None => "❌",
235 };
236 write!(f, "{icon}|")?;
237 }
238
239 if iter.peek().is_some() {
241 writeln!(f)?;
242 }
243 }
244
245 Ok(())
246 }
247}
248
249impl ProjectTable {
250 #[must_use]
253 pub fn display_no_compatibility_icons(&self) -> String {
254 let mut buffer = String::new();
256
257 writeln!(buffer, "|{}|", self.column_names[0]).unwrap();
258 writeln!(buffer, "|:--|").unwrap();
259
260 let mut sorted_project_map: Vec<_> = self.project_map.iter().collect();
261 sorted_project_map.sort_by(|a, b| a.0.name.cmp(&b.0.name));
263
264 let mut iter = sorted_project_map.iter().peekable();
265 while let Some(project) = iter.next() {
266 if let Some(id) = &project.0.id {
267 let mut project_url = "https://modrinth.com/project/".to_string();
269 project_url.push_str(id);
270 write!(buffer, "|[{}]({})|", project.0.name, project_url).unwrap();
271 } else {
272 write!(buffer, "|{}|", project.0.name).unwrap();
273 }
274
275 if iter.peek().is_some() {
277 writeln!(buffer).unwrap();
278 }
279 }
280
281 buffer
282 }
283}
284
285pub struct GitUtils;
289
290impl GitUtils {
291 pub fn initialize_modpack_repo(directory: &Path) -> PackrinthResult<()> {
300 if let Err(error) = gix::init(directory) {
301 if !matches!(
303 &error,
304 gix::init::Error::Init(gix::create::Error::DirectoryExists { path })
305 if path.file_name() == Some(std::ffi::OsStr::new(".git"))
306 ) {
307 return Err(PackrinthError::FailedToInitGitRepoWhileInitModpack {
308 error_message: error.to_string(),
309 });
310 }
311 }
312
313 let gitignore_path = directory.join(".gitignore");
314 if let Ok(exists) = fs::exists(&gitignore_path)
315 && !exists
316 && let Ok(gitignore_file) = OpenOptions::new()
317 .append(true)
318 .create(true)
319 .open(&gitignore_path)
320 {
321 if let Err(error) = writeln!(&gitignore_file, "# Exported files") {
322 return Err(PackrinthError::FailedToWriteFile {
323 path_to_write_to: gitignore_path.display().to_string(),
324 error_message: error.to_string(),
325 });
326 }
327 if let Err(error) = writeln!(&gitignore_file, "{TARGET_DIRECTORY}") {
328 return Err(PackrinthError::FailedToWriteFile {
329 path_to_write_to: gitignore_path.display().to_string(),
330 error_message: error.to_string(),
331 });
332 }
333 if let Err(error) = gitignore_file.sync_all() {
334 return Err(PackrinthError::FailedToWriteFile {
335 path_to_write_to: gitignore_path.display().to_string(),
336 error_message: error.to_string(),
337 });
338 }
339 }
340
341 Ok(())
342 }
343
344 #[must_use]
350 pub fn modpack_is_dirty(modpack: &Modpack) -> bool {
351 let git_repo = match gix::open(&modpack.directory) {
352 Ok(git_repo) => git_repo,
353 Err(_error) => return false,
354 };
355
356 git_repo.is_dirty().unwrap_or(false)
357 }
358}
359
360pub type PackrinthResult<T> = Result<T, PackrinthError>;
362
363#[non_exhaustive]
365#[derive(Debug, Clone, PartialEq, Eq, Hash)]
366pub enum PackrinthError {
367 PathIsFile {
368 path: String,
369 },
370 FailedToCreateDir {
371 dir_to_create: String,
372 error_message: String,
373 },
374 FailedToReadToString {
375 path_to_read: String,
376 error_message: String,
377 },
378 FailedToParseConfigJson {
379 config_path: String,
380 error_message: String,
381 },
382 FailedToParseModrinthResponseJson {
383 modrinth_endpoint: String,
384 error_message: String,
385 },
386 FailedToSerialize {
387 error_message: String,
388 },
389 ProjectIsNotAdded {
390 project: String,
391 },
392 OverrideDoesNotExist {
393 project: String,
394 branch: String,
395 },
396 NoOverridesForProject {
397 project: String,
398 },
399 NoExclusionsForProject {
400 project: String,
401 },
402 NoInclusionsForProject {
403 project: String,
404 },
405 ProjectAlreadyHasExclusions {
406 project: String,
407 },
408 ProjectAlreadyHasInclusions {
409 project: String,
410 },
411 FailedToWriteFile {
412 path_to_write_to: String,
413 error_message: String,
414 },
415 FailedToInitializeFileType {
416 file_to_create: String,
417 error_message: String,
418 },
419 DirectoryExpected {
420 path_that_should_have_been_dir: String,
421 },
422 FailedToStartZipFile {
423 file_to_start: String,
424 error_message: String,
425 },
426 FailedToWriteToZip {
427 to_write: String,
428 error_message: String,
429 },
430 FailedToGetWalkDirEntry {
431 error_message: String,
432 },
433 FailedToStripPath {
434 path: String,
435 },
436 FailedToCopyIntoBuffer,
437 FailedToAddZipDir {
438 zip_dir_path: String,
439 },
440 FailedToFinishZip,
441 BranchDoesNotExist {
442 branch: String,
443 error_message: String,
444 },
445 AttemptedToAddOtherModpack,
446 NoModrinthFilesFoundForProject {
447 project: String,
448 },
449 RequestFailed {
450 url: String,
451 error_message: String,
452 },
453 FailedToGetCurrentDirectory {
454 error_message: String,
455 },
456 InvalidPackFormat {
457 used_pack_format: u16,
458 },
459 NoBranchSpecified,
460 NoInclusionsSpecified,
461 NoExclusionsSpecified,
462 RepoIsDirty,
463 FailedToInitGitRepoWhileInitModpack {
464 error_message: String,
465 },
466 ModpackAlreadyExists {
467 directory: String,
468 },
469 MainModLoaderProvidedButNoVersion,
470 ModpackHasNoBranchesToUpdate,
471 FailedToCreateZipArchive {
472 zip_path: String,
473 error_message: String,
474 },
475 InvalidMrPack {
476 mrpack_path: String,
477 error_message: String,
478 },
479 FailedToExtractMrPack {
480 mrpack_path: String,
481 output_directory: String,
482 error_message: String,
483 },
484 BranchAlreadyExists {
485 branch: String,
486 },
487 FailedToRemoveDir {
488 dir_to_remove: String,
489 error_message: String,
490 },
491 FailedToParseCratesIoResponseJson {
492 crates_io_endpoint: String,
493 error_message: String,
494 },
495 FailedToParseSemverVersion {
496 version_to_parse: String,
497 error_message: String,
498 },
499}
500
501impl PackrinthError {
502 #[must_use]
505 pub fn message_and_tip(&self) -> (String, String) {
506 let file_an_issue: String =
507 "file an issue at https://github.com/Thijzert123/packrinth/issues".to_string();
508 match self {
509 PackrinthError::PathIsFile { path } => (format!("path {path} is a file"), "remove the file or change the target directory".to_string()),
510 PackrinthError::FailedToCreateDir{ dir_to_create, error_message } => (format!("failed to create directory {dir_to_create}: {error_message}"), "check if you have sufficient permissions and if the path already exists".to_string()),
511 PackrinthError::FailedToReadToString { path_to_read, error_message } => (format!("failed to read file {path_to_read}: {error_message}"), "check if you have sufficient permissions and if the file exists".to_string()),
512 PackrinthError::FailedToParseConfigJson { config_path, error_message } => (format!("config file {config_path} is invalid: {error_message}"), "fix it according to JSON standards".to_string()),
513 PackrinthError::FailedToParseModrinthResponseJson { modrinth_endpoint, error_message } => (format!("modrinth response from endpoint {modrinth_endpoint} is invalid: {error_message}"), file_an_issue),
514 PackrinthError::FailedToParseCratesIoResponseJson { crates_io_endpoint, error_message } => (format!("crates.io response from endpoint {crates_io_endpoint} is invalid: {error_message}"), file_an_issue),
515 PackrinthError::FailedToSerialize{ error_message } => (format!("failed to serialize to a JSON: {error_message}"), file_an_issue),
516 PackrinthError::ProjectIsNotAdded { project } => (format!("project {project} is not added to this modpack"), "add it with subcommand: project add".to_string()),
517 PackrinthError::OverrideDoesNotExist { project, branch } => (format!("{project} does not have an override for branch {branch}"), "add one with subcommand: project override add".to_string()),
518 PackrinthError::NoOverridesForProject { project } => (format!("project {project} doesn't have any overrides"), "add one with subcommand: project override add".to_string()),
519 PackrinthError::NoExclusionsForProject { project } => (format!("project {project} doesn't have any exclusions"), "add exclusions with subcommand: project exclude add".to_string()),
520 PackrinthError::NoInclusionsForProject { project } => (format!("project {project} doesn't have any inclusions"), "add inclusions with subcommand: project include add".to_string()),
521 PackrinthError::ProjectAlreadyHasExclusions { project } => (format!("project {project} already has exclusions"), "you can't have both inclusions and exclusions for one project".to_string()),
522 PackrinthError::ProjectAlreadyHasInclusions { project } => (format!("project {project} already has inclusions"), "you can't have both inclusions and exclusions for one project".to_string()),
523 PackrinthError::FailedToWriteFile { path_to_write_to, error_message } => (format!("failed to write to file {path_to_write_to}: {error_message}"), "check if you have sufficient permissions and if the file exists".to_string()),
524 PackrinthError::FailedToInitializeFileType { file_to_create, error_message } => (format!("failed to create file {file_to_create}: {error_message}"), "check if you have sufficient permissions and if the path already exists".to_string()),
525 PackrinthError::DirectoryExpected { path_that_should_have_been_dir } => (format!("expected a directory at {path_that_should_have_been_dir}"), "remove the path if possible".to_string()),
526 PackrinthError::FailedToStartZipFile { file_to_start, error_message } => (format!("failed to start zip file at {file_to_start}: {error_message}"), file_an_issue),
527 PackrinthError::FailedToWriteToZip { to_write, error_message } => (format!("failed to write {to_write} to zip: {error_message}"), file_an_issue),
528 PackrinthError::FailedToGetWalkDirEntry { error_message } => (format!("failed to get entry from WalkDir: {error_message}"), file_an_issue),
529 PackrinthError::FailedToStripPath { path } => (format!("failed to strip path {path}"), file_an_issue),
530 PackrinthError::FailedToCopyIntoBuffer => ("failed to copy data into buffer for zip".to_string(), file_an_issue),
531 PackrinthError::FailedToAddZipDir { zip_dir_path } => (format!("failed to add zip directory {zip_dir_path}"), file_an_issue),
532 PackrinthError::FailedToFinishZip => ("failed to finish zip".to_string(), file_an_issue),
533 PackrinthError::BranchDoesNotExist { branch, error_message } => (format!("branch {branch} doesn't exist: {error_message}"), "add a branch with subcommand: branch add".to_string()),
534 PackrinthError::AttemptedToAddOtherModpack => ("one of the projects is another modpack".to_string(), "remove the modpack project with subcommand: project remove <MODPACK_PROJECT>".to_string()),
535 PackrinthError::NoModrinthFilesFoundForProject { project } => (format!("no files found for project {project}"), "check if the project id is spelled correctly or try to remove or add project inclusions, exclusions or overrides".to_string()),
536 PackrinthError::RequestFailed { url, error_message } => (format!("request to {url} failed: {error_message}"), format!("check your internet connection or {file_an_issue}")),
537 PackrinthError::FailedToGetCurrentDirectory { error_message } => (format!("couldn't get the current directory: {error_message}"), "the current directory may not exist or you have insufficient permissions to access the current directory".to_string()),
538 PackrinthError::InvalidPackFormat { used_pack_format } => (format!("pack format {used_pack_format} is not supported by this Packrinth version"), format!("please use a configuration with pack format {}", config::CURRENT_PACK_FORMAT)),
539 PackrinthError::NoBranchSpecified => ("no branch specified".to_string(), "specify a branch or remove all with the --all flag".to_string()),
540 PackrinthError::NoInclusionsSpecified => ("no inclusions specified".to_string(), "specify inclusions or remove all with the --all flag".to_string()),
541 PackrinthError::NoExclusionsSpecified => ("no exclusions specified".to_string(), "specify exclusions or remove all with the --all flag".to_string()),
542 PackrinthError::RepoIsDirty => ("git repository has uncommitted changes".to_string(), "pass the --allow-dirty flag to force continuing".to_string()),
543 PackrinthError::FailedToInitGitRepoWhileInitModpack { error_message } => (format!("failed to initialize Git repository: {error_message}"), "the modpack itself was initialized successfully, so you can try to initialize a Git repository yourself".to_string()),
544 PackrinthError::ModpackAlreadyExists { directory } => (format!("a modpack instance already exists in {directory}"), "to force initializing a new repository, pass the --force flag".to_string()),
545 PackrinthError::MainModLoaderProvidedButNoVersion => ("a main mod loader was specified for a branch, but no version was provided".to_string(), "add the loader_version to branch.json".to_string()),
546 PackrinthError::ModpackHasNoBranchesToUpdate => ("no branches to update".to_string(), "add a branch with subcommand: branch add".to_string()),
547 PackrinthError::FailedToCreateZipArchive { zip_path, error_message } => (format!("failed to create zip archive for zip at {zip_path}: {error_message}"), "check if you have sufficient permissions and if the zip file exists".to_string()),
548 PackrinthError::InvalidMrPack { mrpack_path, error_message } => (format!("Modrinth pack at {mrpack_path} is invalid: {error_message}"), "make sure you adhere to the specifications (https://support.modrinth.com/en/articles/8802351-modrinth-modpack-format-mrpack)".to_string()),
549 PackrinthError::FailedToExtractMrPack { mrpack_path, output_directory, error_message } => (format!("failed to extract Modrinth pack at {mrpack_path} to {output_directory}: {error_message}"), "check if you have sufficient permissions".to_string()),
550 PackrinthError::BranchAlreadyExists { branch } => (format!("branch {branch} already exists"), "you can still continue by passing the --force flag".to_string()),
551 PackrinthError::FailedToRemoveDir { dir_to_remove, error_message } => (format!("failed to remove directory {dir_to_remove}: {error_message}"), "check if you have sufficient permissions and if the directory exists".to_string()),
552 PackrinthError::FailedToParseSemverVersion { version_to_parse, error_message } => (format!("failed to parse semver version {version_to_parse}: {error_message}"), file_an_issue),
553 }
554 }
555}