libium/
add.rs

1use crate::{
2    config::structs::{Mod, ModIdentifier, ModIdentifierRef, ModLoader, Profile},
3    upgrade::check::{self, game_version_check, mod_loader_check},
4    APIs,
5};
6use serde::Deserialize;
7use std::{collections::HashMap, str::FromStr};
8
9#[derive(thiserror::Error, Debug)]
10pub enum Error {
11    #[error(
12        "The developer of this project has denied third party applications from downloading it"
13    )]
14    /// The user can manually download the mod and place it in the `user` folder of the output directory to mitigate this.
15    /// However, they will have to manually update the mod.
16    DistributionDenied,
17    #[error("The project has already been added")]
18    AlreadyAdded,
19    #[error("The project is not compatible")]
20    Incompatible,
21    #[error("The project does not exist")]
22    DoesNotExist,
23    #[error("The project is not a mod")]
24    NotAMod,
25    #[error("GitHub: {0}")]
26    GitHubError(String),
27    #[error("GitHub: {0:#?}")]
28    OctocrabError(#[from] octocrab::Error),
29    #[error("Modrinth: {0}")]
30    ModrinthError(#[from] ferinth::Error),
31    #[error("CurseForge: {0}")]
32    CurseForgeError(#[from] furse::Error),
33}
34type Result<T> = std::result::Result<T, Error>;
35
36#[derive(Deserialize, Debug)]
37struct GraphQlResponse {
38    data: HashMap<String, Option<ResponseData>>,
39    #[serde(default)]
40    errors: Vec<GraphQLError>,
41}
42
43#[derive(Deserialize, Debug)]
44struct GraphQLError {
45    #[serde(rename = "type")]
46    type_: String,
47    path: Vec<String>,
48    message: String,
49}
50
51#[derive(Deserialize, Debug)]
52struct ResponseData {
53    owner: OwnerData,
54    name: String,
55    releases: ReleaseConnection,
56}
57#[derive(Deserialize, Debug)]
58struct OwnerData {
59    login: String,
60}
61#[derive(Deserialize, Debug)]
62struct ReleaseConnection {
63    nodes: Vec<Release>,
64}
65#[derive(Deserialize, Debug)]
66struct Release {
67    #[serde(rename = "releaseAssets")]
68    assets: ReleaseAssetConnection,
69}
70#[derive(Deserialize, Debug)]
71struct ReleaseAssetConnection {
72    nodes: Vec<ReleaseAsset>,
73}
74#[derive(Deserialize, Debug)]
75struct ReleaseAsset {
76    name: String,
77}
78
79pub fn parse_id(id: String) -> ModIdentifier {
80    if let Ok(id) = id.parse() {
81        ModIdentifier::CurseForgeProject(id)
82    } else {
83        let split = id.split('/').collect::<Vec<_>>();
84        if split.len() == 2 {
85            ModIdentifier::GitHubRepository((split[0].to_owned(), split[1].to_owned()))
86        } else {
87            ModIdentifier::ModrinthProject(id)
88        }
89    }
90}
91
92/// Classify the `identifiers` into the appropriate platforms, send batch requests to get the necessary information,
93/// check details about the projects, and add them to `profile` if suitable.
94/// Performs checks on the mods to see whether they're compatible with the profile if `perform_checks` is true
95pub async fn add(
96    apis: APIs<'_>,
97    profile: &mut Profile,
98    identifiers: Vec<ModIdentifier>,
99    perform_checks: bool,
100    check_game_version: bool,
101    check_mod_loader: bool,
102) -> Result<(Vec<String>, Vec<(String, Error)>)> {
103    let mut mr_ids = Vec::new();
104    let mut cf_ids = Vec::new();
105    let mut gh_ids = Vec::new();
106    let mut errors = Vec::new();
107
108    for id in identifiers {
109        match id {
110            ModIdentifier::CurseForgeProject(id) => cf_ids.push(id),
111            ModIdentifier::ModrinthProject(id) => mr_ids.push(id),
112            ModIdentifier::GitHubRepository(id) => gh_ids.push(id),
113        }
114    }
115
116    let cf_projects = if !cf_ids.is_empty() {
117        cf_ids.sort_unstable();
118        cf_ids.dedup();
119        apis.cf.get_mods(cf_ids.clone()).await?
120    } else {
121        Vec::new()
122    };
123
124    let mr_projects = if !mr_ids.is_empty() {
125        mr_ids.sort_unstable();
126        mr_ids.dedup();
127        apis.mr
128            .get_multiple_projects(&mr_ids.iter().map(AsRef::as_ref).collect::<Vec<_>>())
129            .await?
130    } else {
131        Vec::new()
132    };
133
134    let gh_repos = {
135        // Construct GraphQl query using raw strings
136        let mut graphql_query = "{".to_string();
137        for (i, (owner, name)) in gh_ids.iter().enumerate() {
138            graphql_query.push_str(&format!(
139                "_{i}: repository(owner: \"{owner}\", name: \"{name}\") {{
140                    owner {{
141                        login
142                    }}
143                    name
144                    releases(first: 100) {{
145                      nodes {{
146                        releaseAssets(first: 10) {{
147                          nodes {{
148                            name
149                          }}
150                        }}
151                      }}
152                    }}
153                }}"
154            ));
155        }
156        graphql_query.push('}');
157
158        // Send the query
159        let response: GraphQlResponse = if !gh_ids.is_empty() {
160            apis.gh
161                .graphql(&HashMap::from([("query", graphql_query)]))
162                .await?
163        } else {
164            GraphQlResponse {
165                data: HashMap::new(),
166                errors: Vec::new(),
167            }
168        };
169
170        errors.extend(response.errors.into_iter().map(|v| {
171            (
172                {
173                    let id = &gh_ids[v.path[0]
174                        .strip_prefix('_')
175                        .and_then(|s| s.parse::<usize>().ok())
176                        .expect("Unexpected response data")];
177                    format!("{}/{}", id.0, id.1)
178                },
179                if v.type_ == "NOT_FOUND" {
180                    Error::DoesNotExist
181                } else {
182                    Error::GitHubError(v.message)
183                },
184            )
185        }));
186
187        response
188            .data
189            .into_values()
190            .flatten()
191            .map(|d| {
192                (
193                    (d.owner.login, d.name),
194                    d.releases
195                        .nodes
196                        .into_iter()
197                        .flat_map(|r| r.assets.nodes.into_iter().map(|e| e.name))
198                        .collect::<Vec<_>>(),
199                )
200            })
201            .collect::<Vec<_>>()
202    };
203
204    let mut success_names = Vec::new();
205
206    for project in cf_projects {
207        if let Some(i) = cf_ids.iter().position(|&id| id == project.id) {
208            cf_ids.swap_remove(i);
209        }
210
211        match curseforge(
212            &project,
213            profile,
214            perform_checks,
215            check_game_version,
216            check_mod_loader,
217        ) {
218            Ok(_) => success_names.push(project.name),
219            Err(err) => errors.push((format!("{} ({})", project.name, project.id), err)),
220        }
221    }
222    errors.extend(
223        cf_ids
224            .iter()
225            .map(|id| (id.to_string(), Error::DoesNotExist)),
226    );
227
228    for project in mr_projects {
229        if let Some(i) = mr_ids
230            .iter()
231            .position(|id| id == &project.id || project.slug.eq_ignore_ascii_case(id))
232        {
233            mr_ids.swap_remove(i);
234        }
235
236        match modrinth(
237            &project,
238            profile,
239            perform_checks,
240            check_game_version,
241            check_mod_loader,
242        ) {
243            Ok(_) => success_names.push(project.title),
244            Err(err) => errors.push((format!("{} ({})", project.title, project.id), err)),
245        }
246    }
247    errors.extend(
248        mr_ids
249            .iter()
250            .map(|id| (id.to_string(), Error::DoesNotExist)),
251    );
252
253    for (repo, asset_names) in gh_repos {
254        match github(
255            &repo,
256            profile,
257            Some(&asset_names),
258            check_game_version,
259            check_mod_loader,
260        ) {
261            Ok(_) => success_names.push(format!("{}/{}", repo.0, repo.1)),
262            Err(err) => errors.push((format!("{}/{}", repo.0, repo.1), err)),
263        }
264    }
265
266    Ok((success_names, errors))
267}
268
269/// Check if the repo of `repo_handler` exists, releases mods, and is compatible with `profile`.
270/// If so, add it to the `profile`.
271///
272/// Returns the name of the repository to display to the user
273pub fn github(
274    id: &(impl AsRef<str> + ToString, impl AsRef<str> + ToString),
275    profile: &mut Profile,
276    perform_checks: Option<&[String]>,
277    check_game_version: bool,
278    check_mod_loader: bool,
279) -> Result<()> {
280    // Check if project has already been added
281    if profile.mods.iter().any(|mod_| {
282        mod_.name.eq_ignore_ascii_case(id.1.as_ref())
283            || ModIdentifierRef::GitHubRepository((id.0.as_ref(), id.1.as_ref()))
284                == mod_.identifier.as_ref()
285    }) {
286        return Err(Error::AlreadyAdded);
287    }
288
289    if let Some(asset_names) = perform_checks {
290        // Check if jar files are released
291        if !asset_names.iter().any(|name| name.ends_with(".jar")) {
292            return Err(Error::NotAMod);
293        }
294
295        // Check if the repo is compatible
296        check::github(
297            asset_names,
298            profile.get_version(check_game_version),
299            profile.get_loader(check_mod_loader),
300        )
301        .ok_or(Error::Incompatible)?;
302    }
303
304    // Add it to the profile
305    profile.mods.push(Mod {
306        name: id.1.as_ref().trim().to_string(),
307        identifier: ModIdentifier::GitHubRepository((id.0.to_string(), id.1.to_string())),
308        check_game_version,
309        check_mod_loader,
310    });
311
312    Ok(())
313}
314
315use ferinth::structures::project::{Project, ProjectType};
316
317/// Check if the project of `project_id` has not already been added, is a mod, and is compatible with `profile`.
318/// If so, add it to the `profile`.
319pub fn modrinth(
320    project: &Project,
321    profile: &mut Profile,
322    perform_checks: bool,
323    check_game_version: bool,
324    check_mod_loader: bool,
325) -> Result<()> {
326    // Check if project has already been added
327    if profile.mods.iter().any(|mod_| {
328        mod_.name.eq_ignore_ascii_case(&project.title)
329            || ModIdentifierRef::ModrinthProject(&project.id) == mod_.identifier.as_ref()
330    }) {
331        Err(Error::AlreadyAdded)
332
333    // Check if the project is a mod
334    } else if project.project_type != ProjectType::Mod {
335        Err(Error::NotAMod)
336
337    // Check if the project is compatible
338    } else if !perform_checks // Short circuit if the checks should not be performed
339        || (
340            game_version_check(
341                profile.get_version(check_game_version).as_ref(),
342                &project.game_versions,
343            ) && (
344                mod_loader_check(
345                    profile.get_loader(check_mod_loader),
346                    &project.loaders
347                ) || (
348                // Fabric backwards compatibility in Quilt
349                profile.mod_loader == ModLoader::Quilt
350                    && mod_loader_check(Some(ModLoader::Fabric), &project.loaders)
351                )
352            )
353        )
354    {
355        // Add it to the profile
356        profile.mods.push(Mod {
357            name: project.title.trim().to_owned(),
358            identifier: ModIdentifier::ModrinthProject(project.id.clone()),
359            check_game_version,
360            check_mod_loader,
361        });
362
363        Ok(())
364    } else {
365        Err(Error::Incompatible)
366    }
367}
368
369/// Check if the mod of `project_id` has not already been added, is a mod, and is compatible with `profile`.
370/// If so, add it to the `profile`.
371pub fn curseforge(
372    project: &furse::structures::mod_structs::Mod,
373    profile: &mut Profile,
374    perform_checks: bool,
375    check_game_version: bool,
376    check_mod_loader: bool,
377) -> Result<()> {
378    // Check if project has already been added
379    if profile.mods.iter().any(|mod_| {
380        mod_.name.eq_ignore_ascii_case(&project.name)
381            || ModIdentifier::CurseForgeProject(project.id) == mod_.identifier
382    }) {
383        Err(Error::AlreadyAdded)
384
385    // Check if it can be downloaded by third-parties
386    } else if Some(false) == project.allow_mod_distribution {
387        Err(Error::DistributionDenied)
388
389    // Check if the project is a Minecraft mod
390    } else if !project.links.website_url.as_str().contains("mc-mods") {
391        Err(Error::NotAMod)
392
393    // Check if the mod is compatible
394    } else if !perform_checks // Short-circuit if checks do not have to be performed
395
396        // Extract game version and loader pairs from the 'latest files',
397        // which generally exist for every supported game version and loader combination
398        || {
399            let version = profile.get_version(check_game_version);
400            let loader = profile.get_loader(check_mod_loader);
401            project
402                .latest_files_indexes
403                .iter()
404                .map(|f| {
405                    (
406                        &f.game_version,
407                        f.mod_loader
408                            .as_ref()
409                            .and_then(|l| ModLoader::from_str(&format!("{:?}", l)).ok()),
410                    )
411                })
412                .any(|p| {
413                    (version.is_none() || version == Some(p.0)) &&
414                    (loader.is_none() || loader == p.1)
415                })
416        }
417    {
418        profile.mods.push(Mod {
419            name: project.name.trim().to_string(),
420            identifier: ModIdentifier::CurseForgeProject(project.id),
421            check_game_version,
422            check_mod_loader,
423        });
424
425        Ok(())
426    } else {
427        Err(Error::Incompatible)
428    }
429}