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::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 // TODO: update crawler to use real mod side here
189                    },
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}