conduit_cli/core/installer/
project.rs1use crate::core::error::{CoreError, CoreResult};
2use crate::core::installer::extra_deps::{ExtraDepsPolicy, InstallerUi};
3use crate::core::installer::resolve::{InstallOptions, install_mod};
4use crate::core::installer::sync::sync_from_lock;
5use crate::core::io::project::lock::{LockedMod, ModSide};
6use crate::core::io::project::{ConduitConfig, ConduitLock, ProjectFiles};
7use crate::core::modrinth::ModrinthAPI;
8use crate::core::mods::local::add_local_mods_to_project;
9use crate::core::paths::CorePaths;
10use std::collections::HashSet;
11use std::fs;
12use std::path::PathBuf;
13
14pub struct InstallProjectOptions {
15 pub extra_deps_policy: ExtraDepsPolicy,
16 pub strict: bool,
17 pub force: bool,
18 pub allowed_sides: Vec<ModSide>,
19}
20
21#[derive(Debug, Default, Clone)]
22pub struct SyncProjectReport {
23 pub pruned_files: Vec<String>,
24}
25
26impl Default for InstallProjectOptions {
27 fn default() -> Self {
28 Self {
29 extra_deps_policy: ExtraDepsPolicy::Skip,
30 strict: false,
31 force: false,
32 allowed_sides: vec![ModSide::Both, ModSide::Client, ModSide::Server],
33 }
34 }
35}
36
37pub async fn add_mods_to_project(
38 api: &ModrinthAPI,
39 paths: &CorePaths,
40 inputs: Vec<String>,
41 explicit_deps: Vec<String>,
42 ui: &mut dyn InstallerUi,
43 options: InstallProjectOptions,
44 explicit_side: Option<ModSide>,
45) -> CoreResult<()> {
46 let mut local_paths = Vec::new();
47 let mut local_deps = Vec::new();
48 let mut root_modrinth = Vec::new();
49 let mut dep_modrinth = Vec::new();
50
51 for input in inputs {
52 if let Some(path_str) = input
53 .strip_prefix("f:")
54 .or_else(|| input.strip_prefix("file:"))
55 {
56 local_paths.push(PathBuf::from(path_str));
57 } else {
58 root_modrinth.push(input);
59 }
60 }
61
62 for dep in explicit_deps {
63 if let Some(path_str) = dep.strip_prefix("f:").or_else(|| dep.strip_prefix("file:")) {
64 local_deps.push(PathBuf::from(path_str));
65 } else {
66 dep_modrinth.push(dep);
67 }
68 }
69
70 if !local_paths.is_empty() {
71 add_local_mods_to_project(paths, local_paths, local_deps, explicit_side.as_ref())?;
72 }
73
74 if !root_modrinth.is_empty() || !dep_modrinth.is_empty() {
75 let mut config = ProjectFiles::load_manifest(paths)?;
76 let mut lock = ProjectFiles::load_lock(paths)?;
77
78 for slug in root_modrinth.iter().chain(dep_modrinth.iter()) {
79 if let Err(e) = api.get_project(slug).await {
80 if let Some(req_err) = e.downcast_ref::<reqwest::Error>()
81 && req_err.status() == Some(reqwest::StatusCode::NOT_FOUND)
82 {
83 return Err(CoreError::ProjectNotFound { slug: slug.clone() });
84 }
85
86 return Err(e.into());
87 }
88 }
89
90 for slug in root_modrinth {
91 install_mod(
92 api,
93 paths,
94 &slug,
95 &mut config,
96 &mut lock,
97 ui,
98 InstallOptions {
99 is_root: true,
100 extra_deps_policy: options.extra_deps_policy.clone(),
101 },
102 )
103 .await?;
104 }
105
106 for slug in dep_modrinth {
107 install_mod(
108 api,
109 paths,
110 &slug,
111 &mut config,
112 &mut lock,
113 ui,
114 InstallOptions {
115 is_root: false,
116 extra_deps_policy: options.extra_deps_policy.clone(),
117 },
118 )
119 .await?;
120 }
121
122 ProjectFiles::save_manifest(paths, &config)?;
123 ProjectFiles::save_lock(paths, &lock)?;
124 }
125
126 Ok(())
127}
128
129pub async fn sync_project(
130 api: &ModrinthAPI,
131 paths: &CorePaths,
132 ui: &mut dyn InstallerUi,
133 options: InstallProjectOptions,
134) -> CoreResult<SyncProjectReport> {
135 let mut config = ProjectFiles::load_manifest(paths)?;
136 let mut lock = ProjectFiles::load_lock(paths)?;
137
138 let allowed = if options.allowed_sides.is_empty() {
139 config.instance_type.allowed_sides()
140 } else {
141 options.allowed_sides.clone()
142 };
143
144 if options.force {
145 lock = rebuild_lock_from_config(api, paths, ui, &config, &lock, &options).await?;
146 }
147
148 let missing_locals: Vec<(String, String)> = lock
149 .locked_mods
150 .iter()
151 .filter(|(_, m)| m.url == "local" && allowed.contains(&m.side))
152 .filter(|(_, m)| !paths.mods_dir().join(&m.filename).exists())
153 .map(|(slug, m)| (slug.clone(), m.filename.clone()))
154 .collect();
155
156 if !missing_locals.is_empty() {
157 return Err(CoreError::MissingLocalFiles {
158 mods: missing_locals,
159 });
160 }
161
162 let mods_to_install: Vec<(String, String)> = config
163 .mods
164 .iter()
165 .filter(|(slug, version)| version != &"local" && !lock.locked_mods.contains_key(*slug))
166 .map(|(k, v)| (k.clone(), v.clone()))
167 .collect();
168
169 for (slug, version) in mods_to_install {
170 let input = if version == "latest" {
171 slug.clone()
172 } else {
173 format!("{slug}@{version}")
174 };
175
176 install_mod(
177 api,
178 paths,
179 &input,
180 &mut config,
181 &mut lock,
182 ui,
183 InstallOptions {
184 is_root: true,
185 extra_deps_policy: options.extra_deps_policy.clone(),
186 },
187 )
188 .await?;
189 }
190
191 let mods_to_sync = lock
192 .locked_mods
193 .values()
194 .filter(|m| m.url != "local")
195 .filter(|m| allowed.contains(&m.side));
196
197 sync_from_lock(paths, mods_to_sync, ui).await?;
198
199 let mut report = SyncProjectReport::default();
200 if options.strict {
201 report.pruned_files = prune_unmanaged_mods(paths, &config, &lock, &allowed)?;
202 }
203
204 ProjectFiles::save_manifest(paths, &config)?;
205 ProjectFiles::save_lock(paths, &lock)?;
206
207 Ok(report)
208}
209
210async fn rebuild_lock_from_config(
211 api: &ModrinthAPI,
212 paths: &CorePaths,
213 ui: &mut dyn InstallerUi,
214 config: &ConduitConfig,
215 existing_lock: &ConduitLock,
216 options: &InstallProjectOptions,
217) -> CoreResult<ConduitLock> {
218 let mut new_lock = ConduitLock {
219 conduit_version: env!("CARGO_PKG_VERSION").to_string(),
220 version: existing_lock.version,
221 locked_mods: existing_lock
222 .locked_mods
223 .iter()
224 .filter(|(_k, v)| v.url == "local")
225 .map(|(k, v)| {
226 (
227 k.clone(),
228 LockedMod {
229 id: v.id.clone(),
230 version_id: v.version_id.clone(),
231 filename: v.filename.clone(),
232 url: v.url.clone(),
233 hash: v.hash.clone(),
234 dependencies: v.dependencies.clone(),
235 side: v.side,
236 },
237 )
238 })
239 .collect(),
240 loader_version: existing_lock.loader_version.clone(),
241 };
242
243 let mut dummy_config = config.clone();
244
245 for (slug, version) in &config.mods {
246 if version == "local" {
247 continue;
248 }
249
250 let input = if version == "latest" {
251 slug.clone()
252 } else {
253 format!("{slug}@{version}")
254 };
255
256 install_mod(
257 api,
258 paths,
259 &input,
260 &mut dummy_config,
261 &mut new_lock,
262 ui,
263 InstallOptions {
264 is_root: false,
265 extra_deps_policy: options.extra_deps_policy.clone(),
266 },
267 )
268 .await?;
269 }
270
271 Ok(new_lock)
272}
273
274fn prune_unmanaged_mods(
275 paths: &CorePaths,
276 config: &ConduitConfig,
277 lock: &ConduitLock,
278 allowed_sides: &[ModSide],
279) -> CoreResult<Vec<String>> {
280 let mut managed_files: HashSet<String> = HashSet::new();
281
282 for (key, m) in &lock.locked_mods {
283 if (config.mods.contains_key(key) || m.url != "local") && allowed_sides.contains(&m.side) {
284 managed_files.insert(m.filename.clone());
285 }
286 }
287
288 let mut pruned = Vec::new();
289 if let Ok(read_dir) = fs::read_dir(paths.mods_dir()) {
290 for entry in read_dir {
291 let entry = entry?;
292 let path = entry.path();
293
294 if path.extension().and_then(|e| e.to_str()) != Some("jar") {
295 continue;
296 }
297
298 let filename = match path.file_name().and_then(|n| n.to_str()) {
299 Some(f) => f.to_string(),
300 None => continue,
301 };
302
303 if managed_files.contains(&filename) {
304 continue;
305 }
306
307 fs::remove_file(&path)?;
308 pruned.push(filename);
309 }
310 }
311
312 Ok(pruned)
313}