Skip to main content

conduit_cli/core/installer/
resolve.rs

1use crate::core::error::{CoreError, CoreResult};
2use crate::core::events::CoreEvent;
3use crate::core::installer::download::download_to_path;
4use crate::core::installer::extra_deps::{
5    ExtraDepCandidate, ExtraDepDecision, ExtraDepRequest, ExtraDepsPolicy, InstallerUi,
6};
7use crate::core::io::project::lock::{LockedMod, ModSide};
8use crate::core::io::project::{ConduitConfig, ConduitLock};
9use crate::core::modrinth::ModrinthAPI;
10use crate::core::mods::inspector::JarInspector;
11use crate::core::paths::CorePaths;
12use async_recursion::async_recursion;
13use std::fs;
14use std::path::Path;
15
16pub struct InstallOptions {
17    pub is_root: bool,
18    pub extra_deps_policy: ExtraDepsPolicy,
19}
20
21impl Default for InstallOptions {
22    fn default() -> Self {
23        Self {
24            is_root: true,
25            extra_deps_policy: ExtraDepsPolicy::Skip,
26        }
27    }
28}
29
30pub async fn install_mod(
31    api: &ModrinthAPI,
32    paths: &CorePaths,
33    input: &str,
34    config: &mut ConduitConfig,
35    lock: &mut ConduitLock,
36    ui: &mut dyn InstallerUi,
37    options: InstallOptions,
38) -> CoreResult<()> {
39    fs::create_dir_all(paths.cache_dir())?;
40    fs::create_dir_all(paths.mods_dir())?;
41
42    install_recursive(
43        api,
44        paths,
45        input,
46        config,
47        lock,
48        ui,
49        options.is_root,
50        options.extra_deps_policy,
51    )
52    .await
53}
54
55#[allow(clippy::too_many_arguments)]
56#[allow(clippy::too_many_lines)]
57#[async_recursion(?Send)]
58async fn install_recursive(
59    api: &ModrinthAPI,
60    paths: &CorePaths,
61    input: &str,
62    config: &mut ConduitConfig,
63    lock: &mut ConduitLock,
64    ui: &mut dyn InstallerUi,
65    is_root: bool,
66    extra_deps_policy: ExtraDepsPolicy,
67) -> CoreResult<()> {
68    let parts: Vec<&str> = input.split('@').collect();
69    let slug_or_id = parts[0];
70    let requested_version = parts.get(1).copied();
71
72    let project = api.get_project(slug_or_id).await?;
73    let current_slug = project.slug;
74
75    if lock.locked_mods.contains_key(&current_slug) {
76        if is_root {
77            ui.on_event(CoreEvent::AlreadyInstalled { slug: current_slug });
78        }
79        return Ok(());
80    }
81
82    ui.on_event(CoreEvent::Info(format!("Installing {}", project.title)));
83
84    let loader_filter = config.loader.split('@').next().unwrap_or("fabric");
85
86    let versions = api
87        .get_versions(&current_slug, Some(loader_filter), Some(&config.mc_version))
88        .await?;
89
90    let selected_version = if let Some(req) = requested_version {
91        versions
92            .iter()
93            .find(|v| v.version_number == req || v.id == req)
94            .or_else(|| versions.first())
95    } else {
96        versions.first()
97    }
98    .ok_or_else(|| CoreError::NoCompatibleVersion {
99        slug: current_slug.clone(),
100    })?;
101
102    let file = selected_version
103        .files
104        .iter()
105        .find(|f| f.primary)
106        .or(selected_version.files.first())
107        .ok_or_else(|| CoreError::NoFilesForVersion {
108            version: selected_version.version_number.clone(),
109        })?;
110
111    let sha1 = file.hashes.get("sha1").cloned().unwrap_or_default();
112
113    let cached_path = paths.cache_dir().join(format!("{sha1}.jar"));
114    let dest_path = paths.mods_dir().join(&file.filename);
115
116    if !cached_path.exists() {
117        download_to_path(&file.url, &cached_path, &file.filename, ui).await?;
118    }
119
120    if dest_path.exists() {
121        fs::remove_file(&dest_path)?;
122    }
123    fs::hard_link(&cached_path, &dest_path)?;
124
125    let jar_side = JarInspector::detect_side(&dest_path);
126
127    let final_side = if jar_side == ModSide::Both {
128        let c = project.client_side.as_str();
129        let s = project.server_side.as_str();
130
131        match (c, s) {
132            ("unsupported", _) | ("optional", "required") => ModSide::Server,
133            (_, "unsupported") | ("required", "optional") => ModSide::Client,
134            _ => ModSide::Both,
135        }
136    } else {
137        jar_side
138    };
139
140    if is_root {
141        config.mods.insert(
142            current_slug.clone(),
143            selected_version.version_number.clone(),
144        );
145    }
146
147    let mut current_deps = Vec::new();
148    for dep in &selected_version.dependencies {
149        if dep.dependency_type == "required"
150            && let Some(proj_id) = &dep.project_id
151        {
152            current_deps.push(proj_id.clone());
153        }
154    }
155
156    lock.locked_mods.insert(
157        current_slug.clone(),
158        LockedMod {
159            id: selected_version.project_id.clone(),
160            version_id: selected_version.id.clone(),
161            filename: file.filename.clone(),
162            url: file.url.clone(),
163            hash: sha1,
164            dependencies: current_deps.clone(),
165            side: final_side,
166        },
167    );
168
169    for dep_id in current_deps {
170        install_recursive(
171            api,
172            paths,
173            &dep_id,
174            config,
175            lock,
176            ui,
177            false,
178            extra_deps_policy.clone(),
179        )
180        .await?;
181    }
182
183    if let ExtraDepsPolicy::Skip = extra_deps_policy {
184    } else {
185        let mut ctx = ResolveContext {
186            api,
187            paths,
188            config,
189            lock,
190            ui,
191            extra_deps_policy: extra_deps_policy.clone(),
192        };
193
194        crawl_extra_dependencies(&mut ctx, &dest_path, &current_slug).await?;
195    }
196
197    ui.on_event(CoreEvent::Installed {
198        slug: current_slug,
199        title: project.title,
200    });
201
202    Ok(())
203}
204
205pub struct ResolveContext<'a> {
206    pub api: &'a ModrinthAPI,
207    pub paths: &'a CorePaths,
208    pub config: &'a mut ConduitConfig,
209    pub lock: &'a mut ConduitLock,
210    pub ui: &'a mut dyn InstallerUi,
211    pub extra_deps_policy: ExtraDepsPolicy,
212}
213
214async fn crawl_extra_dependencies(
215    ctx: &mut ResolveContext<'_>,
216    jar_path: &Path,
217    parent_slug: &str,
218) -> CoreResult<()> {
219    let Ok(internal_deps) = JarInspector::inspect_neoforge(jar_path) else {
220        return Ok(());
221    };
222
223    let loader_filter = ctx
224        .config
225        .loader
226        .split('@')
227        .next()
228        .unwrap_or("neoforge")
229        .to_string();
230    let mc_version = ctx.config.mc_version.clone();
231
232    for tech_id in internal_deps {
233        let is_installed = ctx.lock.locked_mods.values().any(|m| m.id == tech_id)
234            || ctx.lock.locked_mods.contains_key(&tech_id)
235            || ctx.config.mods.contains_key(&tech_id);
236
237        if is_installed {
238            continue;
239        }
240
241        let facets = format!("[[\"categories:{loader_filter}\"],[\"versions:{mc_version}\"]]");
242        let search_results = ctx
243            .api
244            .search(&tech_id, 5, 0, "relevance", Some(facets))
245            .await?;
246
247        let mut candidates: Vec<ExtraDepCandidate> = Vec::new();
248
249        let mut exact_match_slug = None;
250        if let Ok(exact) = ctx.api.get_project(&tech_id).await {
251            exact_match_slug = Some(exact.slug.clone());
252            candidates.push(ExtraDepCandidate {
253                title: exact.title,
254                slug: exact.slug,
255                is_exact_match: true,
256            });
257        }
258
259        for hit in &search_results.hits {
260            if Some(&hit.slug) != exact_match_slug.as_ref() {
261                candidates.push(ExtraDepCandidate {
262                    title: hit.title.clone(),
263                    slug: hit.slug.clone(),
264                    is_exact_match: false,
265                });
266            }
267        }
268
269        if candidates.is_empty() {
270            continue;
271        }
272
273        let parent_filename = jar_path
274            .file_name()
275            .and_then(|n| n.to_str())
276            .unwrap_or("unknown file")
277            .to_string();
278
279        let decision = match ctx.extra_deps_policy {
280            ExtraDepsPolicy::Skip => ExtraDepDecision::Skip,
281            ExtraDepsPolicy::AutoExactMatch => exact_match_slug
282                .clone()
283                .map_or(ExtraDepDecision::Skip, ExtraDepDecision::InstallSlug),
284            ExtraDepsPolicy::Callback => ctx.ui.choose_extra_dep(ExtraDepRequest {
285                tech_id: tech_id.clone(),
286                parent_slug: parent_slug.to_string(),
287                parent_filename,
288                candidates,
289            }),
290        };
291
292        let slug_to_install = match decision {
293            ExtraDepDecision::Skip => continue,
294            ExtraDepDecision::InstallSlug(s) => s,
295        };
296
297        ctx.ui.on_event(CoreEvent::Info(format!(
298            "Installing extra dependency {slug_to_install}"
299        )));
300
301        install_recursive(
302            ctx.api,
303            ctx.paths,
304            &slug_to_install,
305            ctx.config,
306            ctx.lock,
307            ctx.ui,
308            false,
309            ctx.extra_deps_policy.clone(),
310        )
311        .await?;
312
313        if let Some(installed_mod) = ctx.lock.locked_mods.get(&slug_to_install) {
314            let installed_id = installed_mod.id.clone();
315            if let Some(parent) = ctx.lock.locked_mods.get_mut(parent_slug)
316                && !parent.dependencies.contains(&installed_id)
317            {
318                parent.dependencies.push(installed_id);
319            }
320        }
321    }
322
323    Ok(())
324}
325
326pub fn ensure_dirs(paths: &CorePaths) -> CoreResult<()> {
327    fs::create_dir_all(paths.cache_dir())?;
328    fs::create_dir_all(paths.mods_dir())?;
329    Ok(())
330}
331
332pub fn hard_link_jar(cache_path: &Path, dest_path: &Path) -> CoreResult<()> {
333    if dest_path.exists() {
334        fs::remove_file(dest_path)?;
335    }
336    fs::hard_link(cache_path, dest_path)?;
337    Ok(())
338}