conduit_cli/core/installer/
resolve.rs1use 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(¤t_slug) {
75 ui.on_event(CoreEvent::AlreadyInstalled { slug: current_slug });
76 return Ok(());
77 }
78
79 if !is_root && lock.locked_mods.contains_key(¤t_slug) {
80 return Ok(());
81 }
82
83 if is_root && lock.locked_mods.contains_key(¤t_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(¤t_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 },
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, ¤t_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}