packrinth/
lib.rs

1//! <div align="center">
2//!   <a href="https://packrinth.thijzert.nl"><img src="https://github.com/Thijzert123/packrinth/blob/ff8455254b966d7879ca2c378a4350c1a56cbfc6/logo.png?raw=true" alt="logo" width=100 height=100 /></a>
3//!
4//!   <h1>Packrinth</h1>
5//!   CLI tool for creating and managing Minecraft modpacks with Modrinth projects
6//!
7//!   <p></p>
8//!
9//!   [![AUR Version](https://img.shields.io/aur/version/packrinth?style=for-the-badge)](https://aur.archlinux.org/packages/packrinth)
10//!   [![Crates.io Version](https://img.shields.io/crates/v/packrinth?style=for-the-badge)](https://crates.io/crates/packrinth)
11//!   [![Crates.io Total Downloads](https://img.shields.io/crates/d/packrinth?style=for-the-badge)](https://crates.io/crates/packrinth)
12//! </div>
13//!
14//! ---
15//!
16//! This library provides utilities for integrating with Packrinth. For example,
17//! the module `config` gives structs for reading and editing Packrinth configuration files.
18//! The module `modrinth` can be used to retrieve data from Modrinth and convert it to
19//! Packrinth-compatible structs.
20//!
21//! If you just want to use the Packrinth CLI, go to <https://packrinth.thijzert.nl>
22//! to see how to use it. You can also use it to get a better understanding of Packrinth's main
23//! principles.
24
25#![warn(clippy::pedantic)]
26
27// All public structs should derive:
28// #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29// Additionally, Serialize, Deserialize, PartialOrd and Ord should only be derived
30// if they make sense in their context.
31
32// Both needed for trait functions, but their name is not directly used.
33use 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
53/// The name of the target directory.
54///
55/// This directory is used for all exported files. It should not be in version control.
56pub 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
94/// The file name of the configuration file inside a `.mrpack` pack.
95///
96/// This file contains all the mods and their metadata of a Modrinth modpack. For more information,
97/// please take a look at the
98/// [official `.mrpack` specification](https://support.modrinth.com/en/articles/8802351-modrinth-modpack-format-mrpack).
99pub const MRPACK_INDEX_FILE_NAME: &str = "modrinth.index.json";
100
101/// A utilization struct used for updating a project for a branch.
102///
103/// The fields in this struct are settings that can be used by
104/// the update function [`ProjectUpdater::update_project`]. You should create a new updater
105/// for every project update cycle, as the settings are different for every project.
106// Allow because these bools aren't here because this struct is a state machine.
107// All bool value combinations are valid, so no worries at all, Clippy!
108#[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/// The result when updating a project.
122#[derive(Debug, Clone, PartialEq, Eq, Hash)]
123pub enum ProjectUpdateResult {
124    /// The project was successfully updated. All dependencies will be returned,
125    /// but the [`Vec`] may be empty.
126    Added(Vec<VersionDependency>),
127
128    /// The project was skipped, because it has inclusions or exclusions specified.
129    Skipped,
130
131    /// The project was not found with the specified preferences and project settings on the
132    /// Modrinth API.
133    NotFound,
134
135    /// Some other error occurred while updating a project.
136    Failed(PackrinthError),
137}
138
139impl ProjectUpdater<'_> {
140    /// Updates a project using the Modrinth API.
141    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/// A table that can be used to show which branches contain which projects.
178///
179/// This can be useful if you provide your modpack for multiple Minecraft versions,
180/// and you want to show which mods are compatible with all the modpack branches.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct ProjectTable {
183    /// The column names are sorted from left to right. The first value should be something like
184    /// `Project` or `Mod name`. All the other values should be the names of the branches.
185    pub column_names: Vec<String>,
186
187    /// The project map that contains information of which projects are available for which branches.
188    /// This [`HashMap`] contains the project as key, and another nested [`HashMap`] as value.
189    /// The nested map contains a branch name as key, and an empty [`Option`] as value.
190    /// [`Some`] with an empty `()` value means that the project is available for the branch,
191    /// while [`None`] means that the project isn't available for the branch.
192    pub project_map: HashMap<BranchFilesProject, HashMap<String, Option<()>>>,
193}
194
195impl Display for ProjectTable {
196    /// Formats the [`ProjectTable`] to a Markdown table. This table will show which projects
197    /// are in the branches using checkmark icons. The resulting Markdown will be *ugly*,
198    /// meaning that a Markdown renderer can show the text correctly, but it may not be the prettiest
199    /// out-of-the-box (without a renderer).
200    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
201        // Write column names
202        writeln!(f, "|{}|", self.column_names.join("|"))?;
203
204        // Write alignment text (:-- is left, :-: is center)
205        write!(f, "|:--|")?;
206        // Use 1..len because column names include the 'Name' for the project column
207        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        // Sort by key (human name of project)
214        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                // If project has an id (not a manual file), write a Markdown link.
220                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            // Sort by key (human name of project)
229            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            // Print newline except for the last time of this loop.
240            if iter.peek().is_some() {
241                writeln!(f)?;
242            }
243        }
244
245        Ok(())
246    }
247}
248
249impl ProjectTable {
250    /// Returns a documentation table [`String`] similar to what `display` produces,
251    /// but without the branch compatibility information.
252    #[must_use]
253    pub fn display_no_compatibility_icons(&self) -> String {
254        // All write macros have an unwrap call, because a write call to a String never fails.
255        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        // Sort by key (human name of project)
262        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                // If project has an id (not a manual file), write a Markdown link.
268                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            // Print newline except for the last time of this loop.
276            if iter.peek().is_some() {
277                writeln!(buffer).unwrap();
278            }
279        }
280
281        buffer
282    }
283}
284
285// TODO extend modrinth api structs to have all possible values, not just the ones required by packrinth
286
287/// Utils for working with a Git-managed modpack instance.
288pub struct GitUtils;
289
290impl GitUtils {
291    /// Initializes a Git repository tailored for use with a Packrinth modpack.
292    ///
293    /// This means that a `.gitignore` file will be made containing the `target` directory,
294    /// the place where all exported `.mrpack` files will be located.
295    ///
296    /// # Errors
297    /// - [`PackrinthError::FailedToInitGitRepoWhileInitModpack`] if initializing the Git repository failed
298    /// - [`PackrinthError::FailedToWriteFile`] if writing to the `.gitignore` file failed
299    pub fn initialize_modpack_repo(directory: &Path) -> PackrinthResult<()> {
300        if let Err(error) = gix::init(directory) {
301            // If the repo already exists, don't show an error.
302            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    /// Checks if the modpack is dirty.
345    ///
346    /// It does this by checking whether the directory of the modpack
347    /// has uncommitted changes. If any errors occur (for example, if no Git repository exists),
348    /// `false` will be returned.
349    #[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
360/// A result with [`PackrinthError`] as [`Err`].
361pub type PackrinthResult<T> = Result<T, PackrinthError>;
362
363/// An error that can occur while performing Packrinth operations.
364#[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    /// Returns a message and tip for a [`PackrinthError`], in the form of (message, tip).
503    /// It uses the relevant data in the enum value.
504    #[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}