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