Skip to main content

conduit_cli/core/installer/
project.rs

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