Skip to main content

modde_sources/nexus/
install.rs

1//! Reusable single-mod install pipeline.
2//!
3//! Shared between `modde-cli`'s `install mod` command and the
4//! `modde-ui` **Browse Nexus → Install** button. The caller is
5//! responsible for:
6//!
7//! * Loading / creating the target profile and wiring the returned
8//!   [`InstallOutcome`] back into it.
9//! * Supplying an [`InstallProbe`] — this crate intentionally does not
10//!   depend on `modde-games`, so the probe has to come from the edge
11//!   (CLI / UI) where `modde_games::resolve_game_plugin` is available.
12//!
13//! This module does the download + extraction + analysis + execution,
14//! and writes the skill dossier when detection fails.
15
16use std::path::{Path, PathBuf};
17
18use anyhow::{Context, Result};
19use reqwest::Client;
20
21use modde_core::installer::{
22    self, DossierContext, InstallMethod, InstallPlan, InstallProbe, InstallerError, ProbeTrace,
23};
24use modde_core::paths;
25use modde_core::{NexusFileId, NexusModId};
26
27use super::api::NexusMod;
28use super::cdn::generate_download_link;
29
30/// Result of the install pipeline, mirroring `modde_cli::install::InstallOutcome`.
31///
32/// The caller decides how to persist each variant:
33/// * `Installed` → `ModdeDb::record_install(..., InstallStatus::Installed)`
34/// * `PendingUserInput` → row with `install_status = 'pending_user_input'`,
35///    route the UI to a wizard
36/// * `Unknown` → row with `install_status = 'unknown'`, surface the dossier
37/// * `AlreadyStaged` → store dir already exists, skip download
38pub enum InstallOutcome {
39    Installed(InstallPlan),
40    PendingUserInput {
41        method: &'static str,
42    },
43    Unknown {
44        dossier_path: PathBuf,
45        method: InstallMethod,
46    },
47    AlreadyStaged,
48}
49
50/// Run the single-mod install pipeline end-to-end.
51///
52/// `client` and `api_key` are used for the CDN download only —
53/// callers that want richer metadata for the dossier can pass in a
54/// `mod_info` fetched separately. `probe` should come from
55/// `modde_games::game_probe(plugin)` for the game this mod belongs to,
56/// or [`InstallProbe::noop`] if the game isn't registered.
57pub async fn install_single_mod(
58    client: &Client,
59    api_key: &str,
60    game_domain: &str,
61    mod_id: NexusModId,
62    file_id: NexusFileId,
63    mod_info: &NexusMod,
64    probe: &InstallProbe,
65) -> Result<InstallOutcome> {
66    let store = paths::store_dir();
67    let mod_store_dir = store.join(format!("{game_domain}_{mod_id}_{file_id}"));
68    if mod_store_dir.exists() {
69        return Ok(InstallOutcome::AlreadyStaged);
70    }
71
72    // Temp staging dir — analyze runs on the raw tree before we move
73    // files into the canonical store layout. See
74    // `modde_cli::commands::install::handle_single_mod` for the mirror
75    // of this flow (kept separate so the CLI can print progress while
76    // the UI can drive it from a `Task::perform`).
77    let staging_root =
78        paths::staging_dir().join(format!("install_{game_domain}_{mod_id}_{file_id}"));
79
80    std::fs::create_dir_all(store.as_path())?;
81    std::fs::create_dir_all(paths::staging_dir().as_path())?;
82
83    // 1. Download via the CDN endpoint. This is gated on Nexus Premium;
84    //    the caller should have verified that before getting here.
85    let download_url = generate_download_link(client, api_key, game_domain, mod_id, file_id)
86        .await
87        .context("failed to get Nexus download link")?;
88    let archive_path = store.join(format!("{mod_id}_{file_id}.zip"));
89    download_with_reqwest(client, &download_url, &archive_path)
90        .await
91        .context("failed to download mod archive")?;
92
93    // 2. Extract into the staging dir (NOT the store).
94    if staging_root.exists() {
95        let _ = std::fs::remove_dir_all(&staging_root);
96    }
97    std::fs::create_dir_all(&staging_root)?;
98    installer::extract_archive(&archive_path, &staging_root)
99        .context("failed to extract mod archive")?;
100
101    // 3. Hash the archive for the plan, then remove it.
102    let source_hash =
103        installer::xxh64_file_hex(&archive_path).context("failed to hash downloaded archive")?;
104    let _ = std::fs::remove_file(&archive_path);
105
106    // 4. Analyze.
107    let mut plan = installer::analyze(&staging_root, probe, source_hash)
108        .context("installer analyze failed")?;
109
110    let outcome = match &plan.method {
111        InstallMethod::Unknown { .. } => {
112            let dossier = write_dossier(
113                &staging_root,
114                game_domain,
115                mod_id,
116                file_id,
117                mod_info,
118                &plan.method,
119            )?;
120            InstallOutcome::Unknown {
121                dossier_path: dossier,
122                method: plan.method.clone(),
123            }
124        }
125        _ if !plan.method.is_ready() => {
126            // FOMOD / BAIN with no config yet — copy the extracted tree
127            // into the store so a wizard can run later without
128            // re-downloading. The CLI and UI paths agree here: the store
129            // is the wizard's working copy.
130            std::fs::create_dir_all(&mod_store_dir)?;
131            copy_dir_tree(&staging_root, &mod_store_dir)
132                .context("failed to copy staging to store for pending install")?;
133            // Leaky abstraction: `method` is a runtime enum so we can't
134            // tie the PendingUserInput string literal to its label. We
135            // only ever produce `fomod` or `bain` here, so match and
136            // pick the right label.
137            let label = match plan.method {
138                InstallMethod::Fomod { .. } => "fomod",
139                InstallMethod::Bain { .. } => "bain",
140                _ => "unknown",
141            };
142            InstallOutcome::PendingUserInput { method: label }
143        }
144        _ => {
145            std::fs::create_dir_all(&mod_store_dir)?;
146            match installer::execute(&mut plan, &staging_root, &mod_store_dir) {
147                Ok(_files) => InstallOutcome::Installed(plan),
148                Err(InstallerError::UnknownMethod { reason: _ }) => {
149                    let dossier = write_dossier(
150                        &staging_root,
151                        game_domain,
152                        mod_id,
153                        file_id,
154                        mod_info,
155                        &plan.method,
156                    )?;
157                    InstallOutcome::Unknown {
158                        dossier_path: dossier,
159                        method: plan.method.clone(),
160                    }
161                }
162                Err(InstallerError::RequiresUserInput { method }) => {
163                    InstallOutcome::PendingUserInput { method }
164                }
165                Err(e) => return Err(e.into()),
166            }
167        }
168    };
169
170    // Clean up staging now that we've either moved files into the
171    // store or written a dossier.
172    let _ = std::fs::remove_dir_all(&staging_root);
173    Ok(outcome)
174}
175
176async fn download_with_reqwest(client: &Client, url: &str, dest: &Path) -> Result<()> {
177    use tokio::io::AsyncWriteExt as _;
178
179    let resp = crate::error::status_error(
180        client
181            .get(url)
182            .send()
183            .await
184            .context("download GET failed")?,
185    )
186    .context("download HTTP error")?;
187    let bytes = resp.bytes().await.context("failed to read download body")?;
188    if let Some(parent) = dest.parent() {
189        std::fs::create_dir_all(parent)?;
190    }
191    let mut file = tokio::fs::File::create(dest).await?;
192    file.write_all(&bytes).await?;
193    file.flush().await?;
194    Ok(())
195}
196
197fn write_dossier(
198    extracted_dir: &Path,
199    game_domain: &str,
200    mod_id: NexusModId,
201    file_id: NexusFileId,
202    mod_info: &NexusMod,
203    method: &InstallMethod,
204) -> Result<PathBuf> {
205    let ctx = DossierContext {
206        game_id: game_domain.to_string(),
207        game_domain: Some(game_domain.to_string()),
208        nexus_mod_id: Some(mod_id),
209        nexus_file_id: Some(file_id),
210        mod_name: mod_info.name.clone(),
211        mod_author: Some(mod_info.author.clone()),
212        mod_version: Some(mod_info.version.clone()),
213        mod_summary: mod_info.summary.clone(),
214        nexus_url: Some(format!(
215            "https://www.nexusmods.com/{game_domain}/mods/{mod_id}"
216        )),
217        // Hash was already consumed by the caller — we don't store it
218        // again here because the dossier context uses it for dedup,
219        // not for integrity. Pass an empty string if we've lost it.
220        source_archive_hash: String::new(),
221    };
222    let trace = vec![ProbeTrace {
223        probe: "generic+game".to_string(),
224        matched: false,
225        note: format!("verdict: {}", method.label()),
226    }];
227    installer::dump_dossier(extracted_dir, &ctx, method, trace)
228        .context("failed to write unknown-installer dossier")
229}
230
231fn copy_dir_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
232    if !src.exists() {
233        return Ok(());
234    }
235    std::fs::create_dir_all(dst)?;
236    for entry in std::fs::read_dir(src)? {
237        let entry = entry?;
238        let src_path = entry.path();
239        let dst_path = dst.join(entry.file_name());
240        if src_path.is_dir() {
241            copy_dir_tree(&src_path, &dst_path)?;
242        } else if src_path.is_file() {
243            std::fs::copy(&src_path, &dst_path)?;
244        }
245    }
246    Ok(())
247}