Skip to main content

conduit_cli/core/installer/
project.rs

1use 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}