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::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(¤t_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(¤t_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, ¤t_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}