conduit_cli/core/installer/
resolve.rs1use crate::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::paths::CorePaths;
9use crate::inspector::JarInspector;
10use crate::lock::{ConduitLock, LockedMod};
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 },
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, ¤t_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}