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::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 = ProjectFiles::load_manifest(paths)?;
68 let mut lock = ProjectFiles::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 ProjectFiles::save_manifest(paths, &config)?;
92 ProjectFiles::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 = ProjectFiles::load_manifest(paths)?;
105 let mut lock = ProjectFiles::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 ProjectFiles::save_manifest(paths, &config)?;
158 ProjectFiles::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 conduit_version: env!("CARGO_PKG_VERSION").to_string(),
173 version: existing_lock.version,
174 locked_mods: existing_lock
175 .locked_mods
176 .iter()
177 .filter(|(_k, v)| v.url == "local")
178 .map(|(k, v)| {
179 (
180 k.clone(),
181 LockedMod {
182 id: v.id.clone(),
183 version_id: v.version_id.clone(),
184 filename: v.filename.clone(),
185 url: v.url.clone(),
186 hash: v.hash.clone(),
187 dependencies: v.dependencies.clone(),
188 side: ModSide::Both },
190 )
191 })
192 .collect(),
193 loader_version: existing_lock.loader_version.clone(),
194 };
195
196 let mut dummy_config = config.clone();
197
198 for (slug, version) in &config.mods {
199 if version == "local" {
200 continue;
201 }
202
203 let input = if version != "latest" {
204 format!("{}@{}", slug, version)
205 } else {
206 slug.clone()
207 };
208
209 install_mod(
210 api,
211 paths,
212 &input,
213 &mut dummy_config,
214 &mut new_lock,
215 ui,
216 InstallOptions {
217 is_root: false,
218 extra_deps_policy: options.extra_deps_policy.clone(),
219 },
220 )
221 .await?;
222 }
223
224 Ok(new_lock)
225}
226
227fn prune_unmanaged_mods(
228 paths: &CorePaths,
229 config: &ConduitConfig,
230 lock: &ConduitLock,
231) -> CoreResult<Vec<String>> {
232 let mut managed_files: HashSet<String> = HashSet::new();
233
234 for (key, m) in &lock.locked_mods {
235 if config.mods.contains_key(key) || m.url != "local" {
236 managed_files.insert(m.filename.clone());
237 }
238 }
239
240 let mut pruned = Vec::new();
241 if let Ok(read_dir) = fs::read_dir(paths.mods_dir()) {
242 for entry in read_dir {
243 let entry = entry?;
244 let path = entry.path();
245 if path.extension().and_then(|e| e.to_str()) != Some("jar") {
246 continue;
247 }
248 let filename = match path.file_name().and_then(|n| n.to_str()) {
249 Some(f) => f.to_string(),
250 None => continue,
251 };
252 if managed_files.contains(&filename) {
253 continue;
254 }
255 fs::remove_file(&path)?;
256 pruned.push(filename);
257 }
258 }
259
260 Ok(pruned)
261}