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::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            side: ModSide::Both // TODO: update crawler to use real mod side here
159        },
160    );
161
162    for dep_id in current_deps {
163        install_recursive(
164            api,
165            paths,
166            &dep_id,
167            config,
168            lock,
169            ui,
170            false,
171            extra_deps_policy.clone(),
172        )
173        .await?;
174    }
175
176    if let ExtraDepsPolicy::Skip = extra_deps_policy {
177    } else {
178        let mut ctx = ResolveContext {
179            api,
180            paths,
181            config,
182            lock,
183            ui,
184            extra_deps_policy: extra_deps_policy.clone(),
185        };
186
187        crawl_extra_dependencies(&mut ctx, &dest_path, &current_slug).await?;
188    }
189
190    ui.on_event(CoreEvent::Installed {
191        slug: current_slug,
192        title: project.title,
193    });
194
195    Ok(())
196}
197
198pub struct ResolveContext<'a> {
199    pub api: &'a ModrinthAPI,
200    pub paths: &'a CorePaths,
201    pub config: &'a mut ConduitConfig,
202    pub lock: &'a mut ConduitLock,
203    pub ui: &'a mut dyn InstallerUi,
204    pub extra_deps_policy: ExtraDepsPolicy,
205}
206
207async fn crawl_extra_dependencies(
208    ctx: &mut ResolveContext<'_>,
209    jar_path: &Path,
210    parent_slug: &str,
211) -> CoreResult<()> {
212    let internal_deps = match JarInspector::inspect_neoforge(jar_path) {
213        Ok(deps) => deps,
214        Err(_) => return Ok(()),
215    };
216
217    let loader_filter = ctx
218        .config
219        .loader
220        .split('@')
221        .next()
222        .unwrap_or("neoforge")
223        .to_string();
224    let mc_version = ctx.config.mc_version.clone();
225
226    for tech_id in internal_deps {
227        let is_installed = ctx.lock.locked_mods.values().any(|m| m.id == tech_id)
228            || ctx.lock.locked_mods.contains_key(&tech_id)
229            || ctx.config.mods.contains_key(&tech_id);
230
231        if is_installed {
232            continue;
233        }
234
235        let facets = format!(
236            "[[\"categories:{}\"],[\"versions:{}\"]]",
237            loader_filter, mc_version
238        );
239        let search_results = ctx
240            .api
241            .search(&tech_id, 5, 0, "relevance", Some(facets))
242            .await?;
243
244        let mut candidates: Vec<ExtraDepCandidate> = Vec::new();
245
246        let mut exact_match_slug = None;
247        if let Ok(exact) = ctx.api.get_project(&tech_id).await {
248            exact_match_slug = Some(exact.slug.clone());
249            candidates.push(ExtraDepCandidate {
250                title: exact.title,
251                slug: exact.slug,
252                is_exact_match: true,
253            });
254        }
255
256        for hit in &search_results.hits {
257            if Some(&hit.slug) != exact_match_slug.as_ref() {
258                candidates.push(ExtraDepCandidate {
259                    title: hit.title.clone(),
260                    slug: hit.slug.clone(),
261                    is_exact_match: false,
262                });
263            }
264        }
265
266        if candidates.is_empty() {
267            continue;
268        }
269
270        let parent_filename = jar_path
271            .file_name()
272            .and_then(|n| n.to_str())
273            .unwrap_or("unknown file")
274            .to_string();
275
276        let decision = match ctx.extra_deps_policy {
277            ExtraDepsPolicy::Skip => ExtraDepDecision::Skip,
278            ExtraDepsPolicy::AutoExactMatch => exact_match_slug
279                .clone()
280                .map(ExtraDepDecision::InstallSlug)
281                .unwrap_or(ExtraDepDecision::Skip),
282            ExtraDepsPolicy::Callback => ctx.ui.choose_extra_dep(ExtraDepRequest {
283                tech_id: tech_id.clone(),
284                parent_slug: parent_slug.to_string(),
285                parent_filename,
286                candidates,
287            }),
288        };
289
290        let slug_to_install = match decision {
291            ExtraDepDecision::Skip => continue,
292            ExtraDepDecision::InstallSlug(s) => s,
293        };
294
295        ctx.ui.on_event(CoreEvent::Info(format!(
296            "Installing extra dependency {slug_to_install}"
297        )));
298
299        install_recursive(
300            ctx.api,
301            ctx.paths,
302            &slug_to_install,
303            ctx.config,
304            ctx.lock,
305            ctx.ui,
306            false,
307            ctx.extra_deps_policy.clone(),
308        )
309        .await?;
310
311        if let Some(installed_mod) = ctx.lock.locked_mods.get(&slug_to_install) {
312            let installed_id = installed_mod.id.clone();
313            if let Some(parent) = ctx.lock.locked_mods.get_mut(parent_slug)
314                && !parent.dependencies.contains(&installed_id) {
315                    parent.dependencies.push(installed_id);
316                }
317        }
318    }
319
320    Ok(())
321}
322
323pub fn ensure_dirs(paths: &CorePaths) -> CoreResult<()> {
324    fs::create_dir_all(paths.cache_dir())?;
325    fs::create_dir_all(paths.mods_dir())?;
326    Ok(())
327}
328
329pub fn hard_link_jar(cache_path: &Path, dest_path: &Path) -> CoreResult<()> {
330    if dest_path.exists() {
331        fs::remove_file(dest_path)?;
332    }
333    fs::hard_link(cache_path, dest_path)?;
334    Ok(())
335}