Skip to main content

modde_core/manifest/
wabbajack.rs

1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8use crate::nexus_id::{NexusFileId, NexusModId};
9use crate::resolver::GameId;
10
11/// Deserialize a Wabbajack hash — accepts base64 string or plain integer.
12fn deserialize_b64_hash<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u64, D::Error> {
13    let val = serde_json::Value::deserialize(deserializer)?;
14    match &val {
15        serde_json::Value::String(s) => {
16            let bytes = BASE64.decode(s).map_err(serde::de::Error::custom)?;
17            if bytes.len() != 8 {
18                return Err(serde::de::Error::custom(format!(
19                    "expected 8 bytes for hash, got {}",
20                    bytes.len()
21                )));
22            }
23            Ok(u64::from_le_bytes(
24                bytes.try_into().expect("length checked to be 8 above"),
25            ))
26        }
27        serde_json::Value::Number(n) => n
28            .as_u64()
29            .ok_or_else(|| serde::de::Error::custom("hash number not a valid u64")),
30        _ => Err(serde::de::Error::custom(
31            "expected string or number for hash",
32        )),
33    }
34}
35
36/// Deserialize Headers field — Wabbajack uses `[]` (empty array) not `{}` (empty object).
37fn deserialize_headers<'de, D: Deserializer<'de>>(
38    deserializer: D,
39) -> Result<HashMap<String, String>, D::Error> {
40    let val = serde_json::Value::deserialize(deserializer)?;
41    match val {
42        serde_json::Value::Object(map) => {
43            let mut result = HashMap::new();
44            for (k, v) in map {
45                if let Some(s) = v.as_str() {
46                    result.insert(k, s.to_string());
47                }
48            }
49            Ok(result)
50        }
51        serde_json::Value::Array(_) => Ok(HashMap::new()),
52        serde_json::Value::Null => Ok(HashMap::new()),
53        _ => Err(serde::de::Error::custom(
54            "expected object or array for Headers",
55        )),
56    }
57}
58
59/// Serialize a u64 hash back to Wabbajack base64 format.
60fn serialize_b64_hash<S: Serializer>(val: &u64, serializer: S) -> Result<S::Ok, S::Error> {
61    let encoded = BASE64.encode(val.to_le_bytes());
62    serializer.serialize_str(&encoded)
63}
64
65/// Parse a base64 hash string into u64 (for use outside serde).
66#[must_use]
67pub fn parse_b64_hash(s: &str) -> Option<u64> {
68    let bytes = BASE64.decode(s).ok()?;
69    if bytes.len() != 8 {
70        return None;
71    }
72    Some(u64::from_le_bytes(
73        bytes.try_into().expect("length checked to be 8 above"),
74    ))
75}
76
77/// Top-level manifest from a `.wabbajack` archive (which is a zip containing JSON).
78#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(rename_all = "PascalCase")]
80pub struct WabbajackManifest {
81    pub name: String,
82    pub author: String,
83    pub description: String,
84    #[serde(alias = "GameType")]
85    pub game: String,
86    pub version: String,
87    #[serde(default)]
88    pub archives: Vec<ArchiveEntry>,
89    #[serde(default)]
90    pub directives: Vec<RawDirective>,
91}
92
93/// Compute a stable identifier for a Wabbajack manifest, derived from its
94/// `name` + `version`. Used as the `manifest_hash` field on
95/// [`crate::profile::LockReason::Wabbajack`] so install and retroactive-scan
96/// flows produce identical IDs for the same modlist.
97///
98/// The hashing scheme is `DefaultHasher::hash(name) + hash(version)` rendered
99/// in lowercase hex — matching the scheme previously inlined at
100/// `crates/modde-cli/src/commands/install.rs:361-367`. Extracted here so
101/// `scan --manifest` can produce bit-identical hashes during retroactive
102/// lock assignment.
103#[must_use]
104pub fn compute_manifest_hash(manifest: &WabbajackManifest) -> String {
105    use std::hash::{Hash, Hasher};
106    let mut hasher = std::collections::hash_map::DefaultHasher::new();
107    manifest.name.hash(&mut hasher);
108    manifest.version.hash(&mut hasher);
109    format!("{:x}", hasher.finish())
110}
111
112/// Copy a `.wabbajack` source file into the content-addressed cache so a
113/// [`crate::profile::LockReason::Wabbajack`] record can be self-verifying even
114/// if the original source moves or is deleted.
115///
116/// Idempotent: if the destination already exists, returns its path without
117/// re-copying — `manifest_hash` is a stable content identifier, so two
118/// different source files that share a hash are treated as equivalent.
119/// Creates the cache directory on demand.
120pub fn cache_wabbajack_file(source: &Path, manifest_hash: &str) -> crate::error::Result<PathBuf> {
121    let dest = crate::paths::wabbajack_cache_path(manifest_hash);
122    if dest.exists() {
123        return Ok(dest);
124    }
125    if let Some(parent) = dest.parent() {
126        std::fs::create_dir_all(parent)?;
127    }
128    std::fs::copy(source, &dest)?;
129    Ok(dest)
130}
131
132/// An archive entry referenced by hash in download/install directives.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(rename_all = "PascalCase")]
135pub struct ArchiveEntry {
136    #[serde(
137        deserialize_with = "deserialize_b64_hash",
138        serialize_with = "serialize_b64_hash"
139    )]
140    pub hash: u64,
141    pub name: String,
142    pub size: u64,
143    #[serde(default)]
144    pub state: Option<ArchiveState>,
145}
146
147impl ArchiveEntry {
148    /// Build this archive's download directive (`None` if it carries no downloadable state).
149    #[must_use]
150    pub fn download_directive(&self) -> Option<DownloadDirective> {
151        let state = self.state.as_ref()?;
152        Some(match state {
153            ArchiveState::NexusDownloader {
154                game_name,
155                mod_id,
156                file_id,
157            } => DownloadDirective::Nexus {
158                game_id: GameId::from(game_name.clone()),
159                mod_id: *mod_id,
160                file_id: *file_id,
161                hash: self.hash,
162            },
163            ArchiveState::GitHubDownloader {
164                user,
165                repo,
166                tag,
167                asset,
168            } => DownloadDirective::GitHub {
169                user: user.clone(),
170                repo: repo.clone(),
171                tag: tag.clone(),
172                asset: asset.clone(),
173                hash: self.hash,
174            },
175            ArchiveState::GoogleDriveDownloader { id } => DownloadDirective::GoogleDrive {
176                id: id.clone(),
177                hash: self.hash,
178            },
179            ArchiveState::MegaDownloader { url } => DownloadDirective::Mega {
180                url: url.clone(),
181                hash: self.hash,
182            },
183            ArchiveState::MediaFireDownloader { url } => DownloadDirective::MediaFire {
184                url: url.clone(),
185                hash: self.hash,
186            },
187            ArchiveState::ManualDownloader { url, prompt } => DownloadDirective::Manual {
188                url: url.clone(),
189                prompt: prompt.clone(),
190                hash: self.hash,
191                expected_name: self.name.clone(),
192            },
193            ArchiveState::HttpDownloader { url, headers } => DownloadDirective::DirectURL {
194                url: url.clone(),
195                headers: headers.clone(),
196                mirror_resolver: None,
197                hash: self.hash,
198            },
199            ArchiveState::ModDBDownloader { url, .. } => DownloadDirective::DirectURL {
200                url: url.clone(),
201                headers: HashMap::new(),
202                mirror_resolver: moddb_html_mirror_resolver(url),
203                hash: self.hash,
204            },
205            ArchiveState::WabbajackCDNDownloader { metadata } => DownloadDirective::WabbajackCdn {
206                url: wabbajack_cdn_url(metadata)?,
207                hash: self.hash,
208            },
209            ArchiveState::GameFileSourceDownloader { .. } => return None,
210        })
211    }
212}
213
214/// Source-specific metadata for an archive.
215#[derive(Debug, Clone, Serialize, Deserialize)]
216#[serde(tag = "$type")]
217pub enum ArchiveState {
218    #[serde(alias = "NexusDownloader, Wabbajack.Lib")]
219    NexusDownloader {
220        #[serde(rename = "GameName")]
221        game_name: String,
222        #[serde(rename = "ModID")]
223        mod_id: NexusModId,
224        #[serde(rename = "FileID")]
225        file_id: NexusFileId,
226    },
227    #[serde(alias = "GitHubDownloader, Wabbajack.Lib")]
228    GitHubDownloader {
229        #[serde(rename = "User")]
230        user: String,
231        #[serde(rename = "Repo")]
232        repo: String,
233        #[serde(rename = "Tag")]
234        tag: String,
235        #[serde(rename = "Asset")]
236        asset: String,
237    },
238    #[serde(alias = "GoogleDriveDownloader, Wabbajack.Lib")]
239    GoogleDriveDownloader {
240        #[serde(rename = "Id")]
241        id: String,
242    },
243    #[serde(alias = "MegaDownloader, Wabbajack.Lib")]
244    MegaDownloader {
245        #[serde(rename = "Url")]
246        url: String,
247    },
248    #[serde(alias = "MediaFireDownloader+State, Wabbajack.Lib")]
249    MediaFireDownloader {
250        #[serde(rename = "Url")]
251        url: String,
252    },
253    #[serde(alias = "ManualDownloader, Wabbajack.Lib")]
254    ManualDownloader {
255        #[serde(rename = "Url")]
256        url: String,
257        #[serde(default, rename = "Prompt")]
258        prompt: String,
259    },
260    #[serde(alias = "HttpDownloader, Wabbajack.Lib")]
261    HttpDownloader {
262        #[serde(rename = "Url")]
263        url: String,
264        #[serde(default, rename = "Headers", deserialize_with = "deserialize_headers")]
265        headers: HashMap<String, String>,
266    },
267    #[serde(alias = "ModDBDownloader, Wabbajack.Lib")]
268    ModDBDownloader {
269        #[serde(rename = "Url")]
270        url: String,
271        #[serde(flatten)]
272        metadata: HashMap<String, serde_json::Value>,
273    },
274    #[serde(alias = "GameFileSourceDownloader, Wabbajack.Lib")]
275    GameFileSourceDownloader {
276        #[serde(flatten)]
277        metadata: HashMap<String, serde_json::Value>,
278    },
279    #[serde(alias = "WabbajackCDNDownloader+State, Wabbajack.Lib")]
280    WabbajackCDNDownloader {
281        #[serde(flatten)]
282        metadata: HashMap<String, serde_json::Value>,
283    },
284}
285
286impl ArchiveState {
287    /// Relative path inside the game install for a Wabbajack game-file source.
288    ///
289    /// Wabbajack has used a few field names for this state over time. Keep the
290    /// parser strict about which string is treated as a path so game/version
291    /// metadata is not accidentally interpreted as a filesystem path.
292    #[must_use]
293    pub fn game_file_path(&self) -> Option<&str> {
294        let Self::GameFileSourceDownloader { metadata } = self else {
295            return None;
296        };
297
298        [
299            "File",
300            "FilePath",
301            "GameFile",
302            "GameFilePath",
303            "Path",
304            "RelativePath",
305        ]
306        .into_iter()
307        .find_map(|key| metadata.get(key).and_then(serde_json::Value::as_str))
308    }
309}
310
311/// A raw directive from the manifest, before we convert to our typed enums.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313#[serde(tag = "$type")]
314pub enum RawDirective {
315    #[serde(alias = "FromArchive, Wabbajack.Lib")]
316    FromArchive {
317        #[serde(rename = "ArchiveHashPath")]
318        archive_hash_path: Vec<serde_json::Value>,
319        #[serde(rename = "To")]
320        to: String,
321        #[serde(default, rename = "Size")]
322        size: u64,
323    },
324    #[serde(alias = "InlineFile, Wabbajack.Lib")]
325    InlineFile {
326        #[serde(
327            rename = "Hash",
328            deserialize_with = "deserialize_b64_hash",
329            serialize_with = "serialize_b64_hash"
330        )]
331        hash: u64,
332        #[serde(rename = "Size")]
333        size: u64,
334        #[serde(rename = "SourceDataID")]
335        source_data_id: String,
336        #[serde(rename = "To")]
337        to: String,
338    },
339    #[serde(alias = "RemappedInlineFile, Wabbajack.Lib")]
340    RemappedInlineFile {
341        #[serde(
342            rename = "Hash",
343            deserialize_with = "deserialize_b64_hash",
344            serialize_with = "serialize_b64_hash"
345        )]
346        hash: u64,
347        #[serde(rename = "Size")]
348        size: u64,
349        #[serde(rename = "SourceDataID")]
350        source_data_id: String,
351        #[serde(rename = "To")]
352        to: String,
353    },
354    #[serde(alias = "PatchedFromArchive, Wabbajack.Lib")]
355    PatchedFromArchive {
356        #[serde(rename = "ArchiveHashPath")]
357        archive_hash_path: Vec<serde_json::Value>,
358        #[serde(rename = "To")]
359        to: String,
360        #[serde(
361            rename = "Hash",
362            deserialize_with = "deserialize_b64_hash",
363            serialize_with = "serialize_b64_hash"
364        )]
365        hash: u64,
366        #[serde(rename = "PatchID")]
367        patch_id: String,
368        #[serde(default, rename = "Size")]
369        size: u64,
370    },
371    #[serde(alias = "CreateBSA, Wabbajack.Lib")]
372    CreateBSA {
373        #[serde(rename = "TempID")]
374        temp_id: String,
375        #[serde(rename = "To")]
376        to: String,
377        #[serde(default, rename = "FileStates")]
378        file_states: Vec<BSAFileState>,
379    },
380    #[serde(other)]
381    Unknown,
382}
383
384/// Our typed download directive enum for downstream consumers.
385#[derive(Debug, Clone, Serialize, Deserialize)]
386pub enum DownloadDirective {
387    Nexus {
388        game_id: GameId,
389        mod_id: NexusModId,
390        file_id: NexusFileId,
391        hash: u64,
392    },
393    GitHub {
394        user: String,
395        repo: String,
396        tag: String,
397        asset: String,
398        hash: u64,
399    },
400    GoogleDrive {
401        id: String,
402        hash: u64,
403    },
404    Mega {
405        url: String,
406        hash: u64,
407    },
408    MediaFire {
409        url: String,
410        hash: u64,
411    },
412    Manual {
413        url: String,
414        prompt: String,
415        hash: u64,
416        expected_name: String,
417    },
418    DirectURL {
419        url: String,
420        headers: HashMap<String, String>,
421        #[serde(default, skip_serializing_if = "Option::is_none")]
422        mirror_resolver: Option<HtmlMirrorResolver>,
423        hash: u64,
424    },
425    WabbajackCdn {
426        url: String,
427        hash: u64,
428    },
429}
430
431/// Optional generic HTML mirror resolver metadata for direct downloads that
432/// point at an intermediate mirror-selection page instead of a file.
433#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
434pub struct HtmlMirrorResolver {
435    pub name: String,
436    pub original_url: String,
437    pub listing_url: String,
438    pub link_id: String,
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub user_agent: Option<String>,
441}
442
443/// Truncate `s` to at most `max` bytes, backing up to a char boundary so a
444/// multibyte codepoint is never split (a byte slice mid-codepoint panics).
445fn truncate_str(s: &str, max: usize) -> &str {
446    let mut end = s.len().min(max);
447    while !s.is_char_boundary(end) {
448        end -= 1;
449    }
450    &s[..end]
451}
452
453impl DownloadDirective {
454    /// Extract the expected hash from any directive variant.
455    #[must_use]
456    pub fn hash(&self) -> u64 {
457        match self {
458            Self::Nexus { hash, .. }
459            | Self::GitHub { hash, .. }
460            | Self::GoogleDrive { hash, .. }
461            | Self::Mega { hash, .. }
462            | Self::MediaFire { hash, .. }
463            | Self::Manual { hash, .. }
464            | Self::DirectURL { hash, .. }
465            | Self::WabbajackCdn { hash, .. } => *hash,
466        }
467    }
468
469    /// Human-readable label for progress/error messages.
470    ///
471    /// Returns `Cow::Borrowed` for variants where the label can be
472    /// computed without allocation (currently none, but future-proofed),
473    /// and `Cow::Owned` when formatting is required.
474    #[must_use]
475    pub fn display_name(&self) -> Cow<'_, str> {
476        match self {
477            Self::Nexus { mod_id, .. } => format!("nexus:{mod_id}").into(),
478            Self::GitHub { repo, .. } => format!("github:{repo}").into(),
479            Self::GoogleDrive { id, .. } => format!("gdrive:{id}").into(),
480            Self::Mega { url, .. } => format!("mega:{}", truncate_str(url, 30)).into(),
481            Self::MediaFire { url, .. } => format!("mediafire:{}", truncate_str(url, 40)).into(),
482            Self::Manual { expected_name, .. } => format!("manual:{expected_name}").into(),
483            Self::DirectURL { url, .. } => format!("http:{}", truncate_str(url, 30)).into(),
484            Self::WabbajackCdn { url, .. } => {
485                format!("wabbajack-cdn:{}", truncate_str(url, 30)).into()
486            }
487        }
488    }
489}
490
491/// Our typed install directive enum.
492#[derive(Debug, Clone, Serialize, Deserialize)]
493pub enum InstallDirective {
494    FromArchive {
495        archive_hash: u64,
496        from: String,
497        inner_path: Option<String>,
498        to: String,
499        size: u64,
500    },
501    InlineFile {
502        source_data_id: String,
503        to: String,
504    },
505    PatchedFromArchive {
506        archive_hash: u64,
507        from: String,
508        inner_path: Option<String>,
509        to: String,
510        patch_id: String,
511        size: u64,
512    },
513    CreateBSA {
514        temp_id: String,
515        to: String,
516        file_states: Vec<BSAFileState>,
517    },
518}
519
520/// An install directive paired with its original manifest directive index.
521#[derive(Debug, Clone, Serialize, Deserialize)]
522pub struct IndexedInstallDirective {
523    pub directive_index: usize,
524    pub directive: InstallDirective,
525}
526
527/// All install directives that read from a single source archive.
528#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct ArchiveInstallBatch {
530    pub archive_hash: u64,
531    pub archive_size_bytes: u64,
532    pub directives: Vec<IndexedInstallDirective>,
533}
534
535impl InstallDirective {
536    fn source_archive_hash(&self) -> Option<u64> {
537        match self {
538            Self::FromArchive { archive_hash, .. }
539            | Self::PatchedFromArchive { archive_hash, .. } => Some(*archive_hash),
540            Self::InlineFile { .. } | Self::CreateBSA { .. } => None,
541        }
542    }
543
544    fn source_inner_path(&self) -> &str {
545        match self {
546            Self::FromArchive { from, .. } | Self::PatchedFromArchive { from, .. } => from,
547            Self::InlineFile { source_data_id, .. } => source_data_id,
548            Self::CreateBSA { temp_id, .. } => temp_id,
549        }
550    }
551}
552
553/// State for a file inside a BSA/BA2 archive.
554#[derive(Debug, Clone, Serialize, Deserialize)]
555#[serde(rename_all = "PascalCase")]
556pub struct BSAFileState {
557    pub path: String,
558    #[serde(
559        default,
560        deserialize_with = "deserialize_b64_hash",
561        serialize_with = "serialize_b64_hash"
562    )]
563    pub hash: u64,
564    #[serde(default)]
565    pub size: u64,
566}
567
568/// Parse a hash from a `serde_json::Value` — tries base64 string first, then numeric.
569fn parse_hash_value(val: Option<&serde_json::Value>) -> u64 {
570    val.and_then(|v| v.as_str().and_then(parse_b64_hash).or_else(|| v.as_u64()))
571        .unwrap_or(0)
572}
573
574impl WabbajackManifest {
575    /// Extract typed download directives from archive entries.
576    #[must_use]
577    pub fn download_directives(&self) -> Vec<DownloadDirective> {
578        self.archives
579            .iter()
580            .filter_map(ArchiveEntry::download_directive)
581            .collect()
582    }
583
584    /// Extract typed install directives from raw directives.
585    #[must_use]
586    pub fn install_directives(&self) -> Vec<InstallDirective> {
587        self.directives
588            .iter()
589            .filter_map(|d| match d {
590                RawDirective::FromArchive {
591                    archive_hash_path,
592                    to,
593                    size,
594                } => {
595                    let hash = parse_hash_value(archive_hash_path.first());
596                    let from = archive_hash_path
597                        .get(1)
598                        .and_then(|v| v.as_str())
599                        .unwrap_or("")
600                        .to_string();
601                    let inner_path = archive_hash_path
602                        .get(2)
603                        .and_then(|v| v.as_str())
604                        .map(ToString::to_string);
605                    Some(InstallDirective::FromArchive {
606                        archive_hash: hash,
607                        from,
608                        inner_path,
609                        to: to.clone(),
610                        size: *size,
611                    })
612                }
613                RawDirective::InlineFile {
614                    source_data_id, to, ..
615                }
616                | RawDirective::RemappedInlineFile {
617                    source_data_id, to, ..
618                } => Some(InstallDirective::InlineFile {
619                    source_data_id: source_data_id.clone(),
620                    to: to.clone(),
621                }),
622                RawDirective::PatchedFromArchive {
623                    archive_hash_path,
624                    to,
625                    patch_id,
626                    size,
627                    ..
628                } => {
629                    let archive_hash = parse_hash_value(archive_hash_path.first());
630                    let from = archive_hash_path
631                        .get(1)
632                        .and_then(|v| v.as_str())
633                        .unwrap_or("")
634                        .to_string();
635                    let inner_path = archive_hash_path
636                        .get(2)
637                        .and_then(|v| v.as_str())
638                        .map(ToString::to_string);
639                    Some(InstallDirective::PatchedFromArchive {
640                        archive_hash,
641                        from,
642                        inner_path,
643                        to: to.clone(),
644                        patch_id: patch_id.clone(),
645                        size: *size,
646                    })
647                }
648                RawDirective::CreateBSA {
649                    temp_id,
650                    to,
651                    file_states,
652                } => Some(InstallDirective::CreateBSA {
653                    temp_id: temp_id.clone(),
654                    to: to.clone(),
655                    file_states: file_states.clone(),
656                }),
657                RawDirective::Unknown => None,
658            })
659            .collect()
660    }
661
662    /// Group archive-backed install directives so each source archive's work is
663    /// scheduled together and can be drained by a batch-scoped reader.
664    #[must_use]
665    pub fn install_directives_grouped_by_archive(&self) -> Vec<ArchiveInstallBatch> {
666        let archive_size_by_hash: HashMap<u64, u64> =
667            self.archives.iter().map(|a| (a.hash, a.size)).collect();
668        let mut by_archive: HashMap<u64, Vec<IndexedInstallDirective>> = HashMap::new();
669
670        for (directive_index, directive) in self.install_directives().into_iter().enumerate() {
671            let Some(archive_hash) = directive.source_archive_hash() else {
672                continue;
673            };
674            by_archive
675                .entry(archive_hash)
676                .or_default()
677                .push(IndexedInstallDirective {
678                    directive_index,
679                    directive,
680                });
681        }
682
683        let mut batches = by_archive
684            .into_iter()
685            .map(|(archive_hash, mut directives)| {
686                directives.sort_by(|a, b| {
687                    a.directive
688                        .source_inner_path()
689                        .cmp(b.directive.source_inner_path())
690                });
691                ArchiveInstallBatch {
692                    archive_hash,
693                    archive_size_bytes: archive_size_by_hash
694                        .get(&archive_hash)
695                        .copied()
696                        .unwrap_or_default(),
697                    directives,
698                }
699            })
700            .collect::<Vec<_>>();
701
702        batches.sort_by_key(|batch| {
703            batch
704                .directives
705                .iter()
706                .map(|directive| directive.directive_index)
707                .min()
708                .unwrap_or(usize::MAX)
709        });
710        batches
711    }
712}
713
714fn moddb_html_mirror_resolver(url: &str) -> Option<HtmlMirrorResolver> {
715    let id = moddb_download_id(url)?;
716    Some(HtmlMirrorResolver {
717        name: "moddb-html-mirror".to_string(),
718        original_url: url.to_string(),
719        listing_url: format!("https://www.moddb.com/downloads/start/{id}/all"),
720        link_id: "downloadon".to_string(),
721        user_agent: Some("Wabbajack/4.0 modde".to_string()),
722    })
723}
724
725fn wabbajack_cdn_url(metadata: &HashMap<String, serde_json::Value>) -> Option<String> {
726    metadata
727        .get("Url")
728        .and_then(serde_json::Value::as_str)
729        .filter(|url| !url.is_empty())
730        .map(str::to_string)
731}
732
733fn moddb_download_id(url: &str) -> Option<&str> {
734    let rest = url.split_once("/downloads/start/")?.1;
735    let id_len = rest
736        .char_indices()
737        .take_while(|(_, ch)| ch.is_ascii_digit())
738        .map(|(idx, ch)| idx + ch.len_utf8())
739        .last()
740        .unwrap_or(0);
741    if id_len == 0 {
742        None
743    } else {
744        Some(&rest[..id_len])
745    }
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751
752    #[test]
753    fn truncate_str_never_splits_a_codepoint() {
754        // 1 ASCII byte + 3-byte chars => byte 30 lands mid-codepoint.
755        let s = format!("x{}", "€".repeat(20));
756        assert!(!s.is_char_boundary(30));
757        let t = truncate_str(&s, 30);
758        assert!(t.len() <= 30 && s.is_char_boundary(t.len()));
759        assert_eq!(truncate_str("abc", 30), "abc");
760    }
761
762    #[test]
763    fn display_name_does_not_panic_on_multibyte_url() {
764        let url = format!("https://mega.nz/{}", "€".repeat(20));
765        let _ = DownloadDirective::Mega { url, hash: 0 }.display_name();
766    }
767
768    #[test]
769    fn game_file_source_downloader_parses_and_is_not_downloaded() {
770        let json = r#"{
771            "Name": "Legends of the Frost synthetic",
772            "Author": "test",
773            "Description": "test",
774            "Game": "SkyrimSE",
775            "Version": "1.0.0",
776            "Archives": [
777                {
778                    "Hash": "AQAAAAAAAAA=",
779                    "Name": "Data_Skyrim.esm",
780                    "Size": 1024,
781                    "State": {
782                        "$type": "GameFileSourceDownloader, Wabbajack.Lib",
783                        "File": "Data\\Skyrim.esm"
784                    }
785                },
786                {
787                    "Hash": "AgAAAAAAAAA=",
788                    "Name": "mod.zip",
789                    "Size": 2048,
790                    "State": {
791                        "$type": "HttpDownloader, Wabbajack.Lib",
792                        "Url": "https://example.invalid/mod.zip",
793                        "Headers": []
794                    }
795                }
796            ],
797            "Directives": [
798                {
799                    "$type": "FromArchive, Wabbajack.Lib",
800                    "ArchiveHashPath": ["AQAAAAAAAAA="],
801                    "To": "mods/Skyrim Base/Skyrim.esm"
802                }
803            ]
804        }"#;
805
806        let manifest: WabbajackManifest = serde_json::from_str(json).unwrap();
807        let source = manifest.archives[0].state.as_ref().unwrap();
808        assert_eq!(source.game_file_path(), Some("Data\\Skyrim.esm"));
809
810        let downloads = manifest.download_directives();
811        assert_eq!(downloads.len(), 1);
812        assert_eq!(downloads[0].hash(), 2);
813
814        let installs = manifest.install_directives();
815        assert!(matches!(
816            installs.as_slice(),
817            [InstallDirective::FromArchive {
818                archive_hash: 1,
819                from,
820                to,
821                ..
822            }] if from.is_empty() && to == "mods/Skyrim Base/Skyrim.esm"
823        ));
824    }
825
826    #[test]
827    fn wabbajack_cdn_downloader_parses_as_authored_download() {
828        let json = r#"{
829            "Name": "Legends of the Frost synthetic",
830            "Author": "test",
831            "Description": "test",
832            "Game": "SkyrimSE",
833            "Version": "1.0.0",
834            "Archives": [
835                {
836                    "Hash": "AQAAAAAAAAA=",
837                    "Name": "Legends of the Frost - Generated Output.7z",
838                    "Size": 1024,
839                    "State": {
840                        "$type": "WabbajackCDNDownloader+State, Wabbajack.Lib",
841                        "Url": "https://authored-files.wabbajack.org/Generated%20Output.7z_abc",
842                        "MungedName": "Generated Output.7z_abc"
843                    }
844                },
845                {
846                    "Hash": "AgAAAAAAAAA=",
847                    "Name": "mod.zip",
848                    "Size": 2048,
849                    "State": {
850                        "$type": "HttpDownloader, Wabbajack.Lib",
851                        "Url": "https://example.invalid/mod.zip",
852                        "Headers": []
853                    }
854                }
855            ],
856            "Directives": []
857        }"#;
858
859        let manifest: WabbajackManifest = serde_json::from_str(json).unwrap();
860        assert!(matches!(
861            manifest.archives[0].state.as_ref(),
862            Some(ArchiveState::WabbajackCDNDownloader { metadata })
863                if metadata.get("MungedName").and_then(serde_json::Value::as_str)
864                    == Some("Generated Output.7z_abc")
865        ));
866
867        let downloads = manifest.download_directives();
868        assert_eq!(downloads.len(), 2);
869        assert!(matches!(
870            &downloads[0],
871            DownloadDirective::WabbajackCdn { url, hash }
872                if url == "https://authored-files.wabbajack.org/Generated%20Output.7z_abc"
873                    && *hash == 1
874        ));
875        assert_eq!(downloads[1].hash(), 2);
876    }
877
878    #[test]
879    fn moddb_downloader_parses_as_direct_url_download() {
880        let json = r#"{
881            "Name": "Legends of the Frost synthetic",
882            "Author": "test",
883            "Description": "test",
884            "Game": "SkyrimSE",
885            "Version": "1.0.0",
886            "Archives": [
887                {
888                    "Hash": "AQAAAAAAAAA=",
889                    "Name": "Skyrim_Realistic_Overhaul_Part_1.7z",
890                    "Size": 1024,
891                    "State": {
892                        "$type": "ModDBDownloader, Wabbajack.Lib",
893                        "Url": "https://www.moddb.com/downloads/start/116891",
894                        "PrimaryKeyString": "ModDBDownloader+State|https://www.moddb.com/downloads/start/116891"
895                    }
896                }
897            ],
898            "Directives": []
899        }"#;
900
901        let manifest: WabbajackManifest = serde_json::from_str(json).unwrap();
902        assert!(matches!(
903            manifest.archives[0].state.as_ref(),
904            Some(ArchiveState::ModDBDownloader { url, metadata })
905                if url == "https://www.moddb.com/downloads/start/116891"
906                    && metadata.contains_key("PrimaryKeyString")
907        ));
908
909        let downloads = manifest.download_directives();
910        assert!(matches!(
911            downloads.as_slice(),
912            [DownloadDirective::DirectURL {
913                url,
914                headers,
915                mirror_resolver,
916                hash
917            }]
918                if url == "https://www.moddb.com/downloads/start/116891"
919                    && headers.is_empty()
920                    && mirror_resolver.as_ref().is_some_and(|resolver| {
921                        resolver.name == "moddb-html-mirror"
922                            && resolver.original_url == "https://www.moddb.com/downloads/start/116891"
923                            && resolver.listing_url == "https://www.moddb.com/downloads/start/116891/all"
924                            && resolver.link_id == "downloadon"
925                            && resolver.user_agent.as_deref() == Some("Wabbajack/4.0 modde")
926                    })
927                    && *hash == 1
928        ));
929    }
930
931    #[test]
932    fn moddb_downloader_derives_mirror_listing_url_with_query_string() {
933        assert_eq!(
934            moddb_download_id("https://www.moddb.com/downloads/start/116927?referer=x"),
935            Some("116927")
936        );
937        let resolver =
938            moddb_html_mirror_resolver("https://www.moddb.com/downloads/start/116927?referer=x")
939                .unwrap();
940        assert_eq!(
941            resolver.listing_url,
942            "https://www.moddb.com/downloads/start/116927/all"
943        );
944    }
945
946    #[test]
947    fn create_bsa_file_state_hash_defaults_when_absent() {
948        let json = r#"{
949            "Name": "Legends of the Frost synthetic",
950            "Author": "test",
951            "Description": "test",
952            "Game": "SkyrimSE",
953            "Version": "1.0.0",
954            "Archives": [],
955            "Directives": [
956                {
957                    "$type": "CreateBSA, Wabbajack.Lib",
958                    "TempID": "textures.bsa",
959                    "To": "mods/Generated/textures.bsa",
960                    "FileStates": [
961                        {
962                            "$type": "BSAFileState, Compression.BSA",
963                            "FlipCompression": false,
964                            "Index": 0,
965                            "Path": "textures\\architecture\\riften\\riftenrope01.dds"
966                        }
967                    ]
968                }
969            ]
970        }"#;
971
972        let manifest: WabbajackManifest = serde_json::from_str(json).unwrap();
973        let installs = manifest.install_directives();
974        assert!(matches!(
975            installs.as_slice(),
976            [InstallDirective::CreateBSA { file_states, .. }]
977                if file_states.len() == 1
978                    && file_states[0].path == "textures\\architecture\\riften\\riftenrope01.dds"
979                    && file_states[0].hash == 0
980        ));
981    }
982
983    #[test]
984    fn remapped_inline_file_parses_as_inline_install_directive() {
985        let json = r#"{
986            "Name": "Twisted synthetic",
987            "Author": "test",
988            "Description": "test",
989            "Game": "SkyrimSpecialEdition",
990            "Version": "1.0.0",
991            "Archives": [],
992            "Directives": [
993                {
994                    "$type": "RemappedInlineFile",
995                    "Hash": "H6Wy/QKDBVE=",
996                    "Size": 6022,
997                    "SourceDataID": "db027c84-eb75-4852-ae01-72cc75abe3a1",
998                    "To": "mods\\BodySlide and Outfit Studio\\CalienteTools\\BodySlide\\Config.xml"
999                }
1000            ]
1001        }"#;
1002
1003        let manifest: WabbajackManifest = serde_json::from_str(json).unwrap();
1004        let installs = manifest.install_directives();
1005        assert!(matches!(
1006            installs.as_slice(),
1007            [InstallDirective::InlineFile { source_data_id, to }]
1008                if source_data_id == "db027c84-eb75-4852-ae01-72cc75abe3a1"
1009                    && to == "mods\\BodySlide and Outfit Studio\\CalienteTools\\BodySlide\\Config.xml"
1010        ));
1011    }
1012
1013    #[test]
1014    fn install_directives_grouped_by_archive_returns_one_batch_per_archive() {
1015        let manifest = WabbajackManifest {
1016            name: "batch synthetic".into(),
1017            author: "test".into(),
1018            description: "test".into(),
1019            game: "SkyrimSE".into(),
1020            version: "1.0.0".into(),
1021            archives: vec![
1022                ArchiveEntry {
1023                    hash: 10,
1024                    name: "a.7z".into(),
1025                    size: 1024,
1026                    state: None,
1027                },
1028                ArchiveEntry {
1029                    hash: 20,
1030                    name: "b.7z".into(),
1031                    size: 2048,
1032                    state: None,
1033                },
1034            ],
1035            directives: vec![
1036                RawDirective::FromArchive {
1037                    archive_hash_path: vec![
1038                        serde_json::Value::Number(10.into()),
1039                        serde_json::Value::String("z-last.txt".into()),
1040                    ],
1041                    to: "mods/a/z-last.txt".into(),
1042                    size: 0,
1043                },
1044                RawDirective::InlineFile {
1045                    hash: 0,
1046                    size: 1,
1047                    source_data_id: "inline".into(),
1048                    to: "mods/inline.txt".into(),
1049                },
1050                RawDirective::PatchedFromArchive {
1051                    archive_hash_path: vec![
1052                        serde_json::Value::Number(20.into()),
1053                        serde_json::Value::String("only.txt".into()),
1054                    ],
1055                    to: "mods/b/only.txt".into(),
1056                    hash: 0,
1057                    patch_id: "patch".into(),
1058                    size: 123,
1059                },
1060                RawDirective::FromArchive {
1061                    archive_hash_path: vec![
1062                        serde_json::Value::Number(10.into()),
1063                        serde_json::Value::String("a-first.txt".into()),
1064                    ],
1065                    to: "mods/a/a-first.txt".into(),
1066                    size: 0,
1067                },
1068                RawDirective::CreateBSA {
1069                    temp_id: "temp".into(),
1070                    to: "mods/out.bsa".into(),
1071                    file_states: vec![],
1072                },
1073            ],
1074        };
1075
1076        let batches = manifest.install_directives_grouped_by_archive();
1077        assert_eq!(batches.len(), 2);
1078        assert_eq!(batches[0].archive_hash, 10);
1079        assert_eq!(batches[0].archive_size_bytes, 1024);
1080        assert_eq!(batches[0].directives.len(), 2);
1081        assert_eq!(batches[0].directives[0].directive_index, 3);
1082        assert!(matches!(
1083            &batches[0].directives[0].directive,
1084            InstallDirective::FromArchive { from, .. } if from == "a-first.txt"
1085        ));
1086        assert_eq!(batches[0].directives[1].directive_index, 0);
1087        assert!(matches!(
1088            &batches[1].directives[..],
1089            [IndexedInstallDirective {
1090                directive_index: 2,
1091                directive: InstallDirective::PatchedFromArchive {
1092                    archive_hash: 20,
1093                    size: 123,
1094                    ..
1095                }
1096            }]
1097        ));
1098    }
1099}