Skip to main content

conduit_cli/core/installer/
resolve.rs

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