Skip to main content

thunderstore_engine/
models.rs

1#![deny(missing_docs)]
2
3use crate::error::Error;
4use crate::intern::{
5  InternKey, StringInterner, intern_option, intern_vec, resolve_option, resolve_vec,
6};
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeSet;
9use std::collections::HashMap;
10use std::collections::HashSet;
11use time::OffsetDateTime;
12
13/// An in-memory index of every package (and all of their versions) in a
14/// Thunderstore community.
15///
16/// This is the crate's central read model. Obtain one from
17/// [`ThunderstoreClient::get_manifest`](crate::client::ThunderstoreClient::get_manifest)
18/// or [`refresh_index`](crate::client::ThunderstoreClient::refresh_index), then
19/// query it for packages, versions, and download URLs.
20///
21/// Packages are addressed by a numeric index in the range `0..len()`. To look up
22/// a package by its `Owner-Name` identifier, build a map once with
23/// [`build_name_index`](PackageIndex::build_name_index), or use
24/// [`get_package_by_full_name`](PackageIndex::get_package_by_full_name). To pull a
25/// single field without materializing a whole [`Package`], use the `resolve_*_at`
26/// accessors.
27///
28/// A `PackageIndex` is built from a `Vec<Package>` (via [`From`]) — for example
29/// the JSON returned by the Thunderstore API — and is what the client persists to
30/// and loads from its on-disk cache.
31#[derive(Debug, Clone)]
32pub struct PackageIndex {
33  pub(crate) interner: StringInterner,
34  pub(crate) names: Vec<Option<InternKey>>,
35  pub(crate) full_names: Vec<Option<InternKey>>,
36  pub(crate) owners: Vec<Option<InternKey>>,
37  pub(crate) package_urls: Vec<Option<InternKey>>,
38  pub(crate) dates_created: Vec<OffsetDateTime>,
39  pub(crate) dates_updated: Vec<OffsetDateTime>,
40  pub(crate) uuid4s: Vec<Option<InternKey>>,
41  pub(crate) rating_scores: Vec<Option<u32>>,
42  pub(crate) is_pinned: Vec<Option<bool>>,
43  pub(crate) is_deprecated: Vec<Option<bool>>,
44  pub(crate) has_nsfw_content: Vec<Option<bool>>,
45  pub(crate) categories: Vec<Vec<InternKey>>,
46  pub(crate) version_ranges: Vec<(usize, usize)>,
47  pub(crate) versions: VersionTable,
48}
49
50/// Struct-of-Arrays representation of version data with interned strings.
51///
52/// Separates frequently accessed "hot" data from rarely accessed "cold" metadata
53/// for better cache performance and memory efficiency. String values are stored as
54/// interned keys that reference the index's string interner.
55#[derive(Debug, Clone)]
56pub(crate) struct VersionTable {
57  pub package_indices: Vec<usize>,
58  pub names: Vec<Option<InternKey>>,
59  pub full_names: Vec<Option<InternKey>>,
60  pub version_numbers: Vec<Option<InternKey>>,
61  pub download_urls: Vec<Option<InternKey>>,
62  pub dependencies: Vec<Vec<InternKey>>,
63  pub dates_created: Vec<OffsetDateTime>,
64  pub descriptions: Vec<Option<InternKey>>,
65  pub icons: Vec<Option<InternKey>>,
66  pub downloads: Vec<Option<u32>>,
67  pub website_urls: Vec<Option<InternKey>>,
68  pub is_active: Vec<Option<bool>>,
69  pub uuid4s: Vec<Option<InternKey>>,
70  pub file_sizes: Vec<Option<u64>>,
71}
72
73/// The on-disk cache format version. `bincode` is not self-describing, so this
74/// leading tag lets a build reject a cache written by an incompatible layout
75/// (rather than silently misdecoding it) — bump it on any change to the
76/// serialized field layout.
77///
78/// Version 2 switched the cache from the `bincode` serde bridge to `bincode`'s
79/// native `Encode`/`Decode` (so container allocation is bounded by the decode
80/// limit) and stores timestamps as `i128` Unix-nanosecond values.
81pub(crate) const CACHE_FORMAT_VERSION: u32 = 2;
82
83/// On-disk (`bincode`) representation of a [`PackageIndex`].
84///
85/// This structure converts the runtime PackageIndex into a format that can be
86/// efficiently serialized and deserialized. The string interner is converted to a simple
87/// vector of strings (the string table), and all interned keys become u32 indices into
88/// this table.
89///
90/// This separation allows for optimal serialization performance while maintaining the
91/// memory efficiency benefits of string interning at runtime.
92///
93/// This is the on-disk cache encoding written and read by [`crate::client`]
94/// (`zstd` + `bincode`). Each field parallels the same-named field of
95/// [`PackageIndex`], with interned keys stored as `u32` indices into
96/// `string_table`.
97///
98/// Encoded and decoded with `bincode`'s native `Encode`/`Decode` (not the serde
99/// bridge) so the decode limit bounds container allocation, and timestamps are
100/// stored as `i128` Unix nanoseconds (see [`encode_dates`]/[`decode_dates`])
101/// because `time::OffsetDateTime` is not a native `bincode` type.
102#[derive(bincode::Encode, bincode::Decode, Debug, Clone)]
103pub(crate) struct SerializedPackageIndex {
104  /// Cache-format tag; must equal [`CACHE_FORMAT_VERSION`]. Kept first so it is
105  /// the first value decoded from the stream.
106  pub format_version: u32,
107  pub string_table: Vec<String>,
108  pub names: Vec<Option<u32>>,
109  pub full_names: Vec<Option<u32>>,
110  pub owners: Vec<Option<u32>>,
111  pub package_urls: Vec<Option<u32>>,
112  /// Package creation/update times as `i128` Unix nanoseconds.
113  pub dates_created: Vec<i128>,
114  pub dates_updated: Vec<i128>,
115  pub uuid4s: Vec<Option<u32>>,
116  pub rating_scores: Vec<Option<u32>>,
117  pub is_pinned: Vec<Option<bool>>,
118  pub is_deprecated: Vec<Option<bool>>,
119  pub has_nsfw_content: Vec<Option<bool>>,
120  pub categories: Vec<Vec<u32>>,
121  pub version_ranges: Vec<(usize, usize)>,
122  pub versions: SerializedVersionTable,
123}
124
125/// Serializable representation of interned version data.
126///
127/// Contains version information with string values represented as u32 indices into
128/// the parent manifest's string table. This allows efficient serialization while
129/// maintaining the benefits of string interning.
130#[derive(bincode::Encode, bincode::Decode, Debug, Clone)]
131pub(crate) struct SerializedVersionTable {
132  pub package_indices: Vec<usize>,
133  pub names: Vec<Option<u32>>,
134  pub full_names: Vec<Option<u32>>,
135  pub version_numbers: Vec<Option<u32>>,
136  pub download_urls: Vec<Option<u32>>,
137  pub dependencies: Vec<Vec<u32>>,
138  /// Version creation times as `i128` Unix nanoseconds.
139  pub dates_created: Vec<i128>,
140  pub descriptions: Vec<Option<u32>>,
141  pub icons: Vec<Option<u32>>,
142  pub downloads: Vec<Option<u32>>,
143  pub website_urls: Vec<Option<u32>>,
144  pub is_active: Vec<Option<bool>>,
145  pub uuid4s: Vec<Option<u32>>,
146  pub file_sizes: Vec<Option<u64>>,
147}
148
149impl PackageIndex {
150  /// Returns the number of packages in the index.
151  pub fn len(&self) -> usize {
152    self.names.len()
153  }
154
155  /// Returns true if the index contains no packages.
156  #[allow(dead_code)]
157  pub fn is_empty(&self) -> bool {
158    self.names.is_empty()
159  }
160
161  /// Returns the short package name at `idx`, or `None` if `idx` is out of range
162  /// or the name is not set.
163  pub fn resolve_name_at(&self, idx: usize) -> Option<String> {
164    resolve_option(&self.interner, *self.names.get(idx)?)
165  }
166
167  /// Returns the full package name (`Owner-Name`) at `idx`, or `None` if `idx` is
168  /// out of range or the name is not set.
169  pub fn resolve_full_name_at(&self, idx: usize) -> Option<String> {
170    resolve_option(&self.interner, *self.full_names.get(idx)?)
171  }
172
173  /// The indices of every package whose name or `Owner-Name` full name contains
174  /// `term`, case-insensitively.
175  ///
176  /// Returns indices rather than references so a caller can resolve only the
177  /// fields it needs from the interned index. An empty `term` matches every
178  /// package, so clearing a search box shows the full list rather than nothing.
179  pub fn search(&self, term: &str) -> Vec<usize> {
180    let needle = term.to_lowercase();
181
182    (0..self.len())
183      .filter(|&idx| {
184        if let Some(name) = self.resolve_name_at(idx)
185          && name.to_lowercase().contains(&needle)
186        {
187          return true;
188        }
189
190        self
191          .resolve_full_name_at(idx)
192          .is_some_and(|full_name| full_name.to_lowercase().contains(&needle))
193      })
194      .collect()
195  }
196
197  /// Returns the owner of the package at `idx`, or `None` if `idx` is out of
198  /// range or the owner is not set.
199  pub fn resolve_owner_at(&self, idx: usize) -> Option<String> {
200    resolve_option(&self.interner, *self.owners.get(idx)?)
201  }
202
203  /// Borrows the short package name at `idx`, or `None` if `idx` is out of range
204  /// or the name is not set.
205  ///
206  /// Unlike [`resolve_name_at`](Self::resolve_name_at), this borrows from the
207  /// index's interner and performs no allocation.
208  pub fn name_at(&self, idx: usize) -> Option<&str> {
209    self
210      .names
211      .get(idx)
212      .copied()
213      .flatten()
214      .map(|k| self.interner.resolve(&k))
215  }
216
217  /// Borrows the full package name (`Owner-Name`) at `idx`, or `None` if `idx` is
218  /// out of range or the name is not set.
219  ///
220  /// Unlike [`resolve_full_name_at`](Self::resolve_full_name_at), this borrows
221  /// from the index's interner and performs no allocation.
222  pub fn full_name_at(&self, idx: usize) -> Option<&str> {
223    self
224      .full_names
225      .get(idx)
226      .copied()
227      .flatten()
228      .map(|k| self.interner.resolve(&k))
229  }
230
231  /// Borrows the owner of the package at `idx`, or `None` if `idx` is out of
232  /// range or the owner is not set.
233  ///
234  /// Unlike [`resolve_owner_at`](Self::resolve_owner_at), this borrows from the
235  /// index's interner and performs no allocation.
236  pub fn owner_at(&self, idx: usize) -> Option<&str> {
237    self
238      .owners
239      .get(idx)
240      .copied()
241      .flatten()
242      .map(|k| self.interner.resolve(&k))
243  }
244
245  /// Looks up a package by its full name (`Owner-Name`) and materializes it,
246  /// returning `None` if no package matches.
247  #[allow(dead_code)]
248  pub fn get_package_by_full_name(&self, full_name: &str) -> Option<Package> {
249    let idx = self.find_index_by_full_name(full_name)?;
250
251    self.get_package_at(idx)
252  }
253
254  /// Returns the index of the package with the given full name (`Owner-Name`),
255  /// or `None` if no package matches.
256  #[allow(dead_code)]
257  pub fn find_index_by_full_name(&self, full_name: &str) -> Option<usize> {
258    self.full_names.iter().position(|key| {
259      key
260        .map(|k| self.interner.resolve(&k) == full_name)
261        .unwrap_or(false)
262    })
263  }
264
265  /// Materializes a full [`Package`], including all of its versions, from the
266  /// package at `idx`, or `None` if `idx` is out of range.
267  #[allow(dead_code)]
268  pub fn get_package_at(&self, idx: usize) -> Option<Package> {
269    let (version_start, version_end) = *self.version_ranges.get(idx)?;
270    let mut versions = Vec::with_capacity(version_end - version_start);
271
272    for ver_idx in version_start..version_end {
273      versions.push(Version {
274        name: resolve_option(&self.interner, self.versions.names[ver_idx]),
275        full_name: resolve_option(&self.interner, self.versions.full_names[ver_idx]),
276        description: resolve_option(&self.interner, self.versions.descriptions[ver_idx]),
277        icon: resolve_option(&self.interner, self.versions.icons[ver_idx]),
278        version_number: resolve_option(&self.interner, self.versions.version_numbers[ver_idx]),
279        dependencies: resolve_vec(&self.interner, &self.versions.dependencies[ver_idx]),
280        download_url: resolve_option(&self.interner, self.versions.download_urls[ver_idx]),
281        downloads: self.versions.downloads[ver_idx],
282        date_created: self.versions.dates_created[ver_idx],
283        website_url: resolve_option(&self.interner, self.versions.website_urls[ver_idx]),
284        is_active: self.versions.is_active[ver_idx],
285        uuid4: resolve_option(&self.interner, self.versions.uuid4s[ver_idx]),
286        file_size: self.versions.file_sizes[ver_idx],
287      });
288    }
289
290    Some(Package {
291      name: resolve_option(&self.interner, self.names[idx]),
292      full_name: resolve_option(&self.interner, self.full_names[idx]),
293      owner: resolve_option(&self.interner, self.owners[idx]),
294      package_url: resolve_option(&self.interner, self.package_urls[idx]),
295      date_created: self.dates_created[idx],
296      date_updated: self.dates_updated[idx],
297      uuid4: resolve_option(&self.interner, self.uuid4s[idx]),
298      rating_score: self.rating_scores[idx],
299      is_pinned: self.is_pinned[idx],
300      is_deprecated: self.is_deprecated[idx],
301      has_nsfw_content: self.has_nsfw_content[idx],
302      categories: resolve_vec(&self.interner, &self.categories[idx]),
303      versions,
304    })
305  }
306
307  /// Returns the index of the latest version of the package at `idx`, chosen by
308  /// most recent creation date, or `None` if `idx` is out of range or the
309  /// package has no versions.
310  ///
311  /// The returned value is an index into the internal (crate-private) version
312  /// table, so this is `pub(crate)`: exposing it publicly would hand callers an
313  /// opaque `usize` that no public accessor consumes and that is trivially
314  /// confused with a package index.
315  pub(crate) fn get_latest_version_at(&self, idx: usize) -> Option<usize> {
316    let (ver_start, ver_end) = *self.version_ranges.get(idx)?;
317
318    if ver_start >= ver_end {
319      return None;
320    }
321
322    (ver_start..ver_end).max_by_key(|&ver_idx| self.versions.dates_created[ver_idx])
323  }
324
325  /// Builds a map from each package's full name (`Owner-Name`) to its index,
326  /// for repeated by-name lookups.
327  pub fn build_name_index(&self) -> HashMap<String, usize> {
328    self
329      .full_names
330      .iter()
331      .enumerate()
332      .filter_map(|(idx, key)| {
333        let name = resolve_option(&self.interner, *key)?;
334        Some((name, idx))
335      })
336      .collect()
337  }
338
339  /// Checks the index for internal consistency.
340  ///
341  /// Returns `Ok(())` if the index is well-formed, otherwise an
342  /// [`Error::Cache`] describing the first problem found. The client runs this
343  /// automatically when loading a cached index, so a corrupt or truncated cache
344  /// surfaces as a recoverable error rather than a later panic.
345  pub fn validate(&self) -> crate::Result<()> {
346    self.validate_lengths()?;
347    self.validate_version_ranges()?;
348    self.validate_version_ownership()?;
349    self.validate_unique_full_names()?;
350
351    Ok(())
352  }
353
354  /// Checks that every parallel struct-of-arrays vector has the length its group
355  /// requires: package-level vectors match the package count, version-level
356  /// vectors match the version count.
357  fn validate_lengths(&self) -> crate::Result<()> {
358    let pkg_count = self.names.len();
359
360    check_len("full_names", self.full_names.len(), pkg_count)?;
361    check_len("owners", self.owners.len(), pkg_count)?;
362    check_len("package_urls", self.package_urls.len(), pkg_count)?;
363    check_len("dates_created", self.dates_created.len(), pkg_count)?;
364    check_len("dates_updated", self.dates_updated.len(), pkg_count)?;
365    check_len("uuid4s", self.uuid4s.len(), pkg_count)?;
366    check_len("rating_scores", self.rating_scores.len(), pkg_count)?;
367    check_len("is_pinned", self.is_pinned.len(), pkg_count)?;
368    check_len("is_deprecated", self.is_deprecated.len(), pkg_count)?;
369    check_len("has_nsfw_content", self.has_nsfw_content.len(), pkg_count)?;
370    check_len("categories", self.categories.len(), pkg_count)?;
371    check_len("version_ranges", self.version_ranges.len(), pkg_count)?;
372
373    let version_count = self.versions.package_indices.len();
374
375    check_len("versions.names", self.versions.names.len(), version_count)?;
376    check_len(
377      "versions.full_names",
378      self.versions.full_names.len(),
379      version_count,
380    )?;
381    check_len(
382      "versions.version_numbers",
383      self.versions.version_numbers.len(),
384      version_count,
385    )?;
386    check_len(
387      "versions.download_urls",
388      self.versions.download_urls.len(),
389      version_count,
390    )?;
391    check_len(
392      "versions.dependencies",
393      self.versions.dependencies.len(),
394      version_count,
395    )?;
396    check_len(
397      "versions.dates_created",
398      self.versions.dates_created.len(),
399      version_count,
400    )?;
401    check_len(
402      "versions.descriptions",
403      self.versions.descriptions.len(),
404      version_count,
405    )?;
406    check_len("versions.icons", self.versions.icons.len(), version_count)?;
407    check_len(
408      "versions.downloads",
409      self.versions.downloads.len(),
410      version_count,
411    )?;
412    check_len(
413      "versions.website_urls",
414      self.versions.website_urls.len(),
415      version_count,
416    )?;
417    check_len(
418      "versions.is_active",
419      self.versions.is_active.len(),
420      version_count,
421    )?;
422    check_len("versions.uuid4s", self.versions.uuid4s.len(), version_count)?;
423    check_len(
424      "versions.file_sizes",
425      self.versions.file_sizes.len(),
426      version_count,
427    )?;
428
429    Ok(())
430  }
431
432  /// Checks that every version range is ordered and in-bounds, and that every
433  /// version's recorded `package_index` refers to a real package. Must run before
434  /// [`validate_version_ownership`](Self::validate_version_ownership), which
435  /// slices `package_indices` by these ranges.
436  fn validate_version_ranges(&self) -> crate::Result<()> {
437    let pkg_count = self.names.len();
438    let version_count = self.versions.package_indices.len();
439
440    for (idx, (start, end)) in self.version_ranges.iter().enumerate() {
441      if start > end {
442        return Err(Error::Cache(format!(
443          "Invalid version range at package {}: {} > {}",
444          idx, start, end
445        )));
446      }
447
448      if *end > version_count {
449        return Err(Error::Cache(format!(
450          "Version range at package {} ends at {} but only {} versions exist",
451          idx, end, version_count
452        )));
453      }
454    }
455
456    for (i, &pkg_idx) in self.versions.package_indices.iter().enumerate() {
457      if pkg_idx >= pkg_count {
458        return Err(Error::Cache(format!(
459          "version {} references package index {} but only {} packages exist",
460          i, pkg_idx, pkg_count
461        )));
462      }
463    }
464
465    Ok(())
466  }
467
468  /// Checks that every version is owned by exactly one package: its slot falls
469  /// inside exactly one package's contiguous range, and that package's recorded
470  /// `package_indices` entry agrees. This rejects a corrupt cache whose ranges are
471  /// individually in-bounds but overlap, leave gaps, or disagree with
472  /// `package_indices` — which would otherwise attach the wrong versions (and
473  /// wrong download URLs) to a package.
474  fn validate_version_ownership(&self) -> crate::Result<()> {
475    let version_count = self.versions.package_indices.len();
476
477    let mut owner_of: Vec<Option<usize>> = vec![None; version_count];
478
479    for (pkg_idx, range) in self.version_ranges.iter().enumerate() {
480      let (start, end) = *range;
481
482      for (offset, &owner_pkg) in self.versions.package_indices[start..end].iter().enumerate() {
483        let ver_idx = start + offset;
484
485        if owner_of[ver_idx].is_some() {
486          return Err(Error::Cache(format!(
487            "version {} is claimed by multiple packages (overlapping version ranges)",
488            ver_idx
489          )));
490        }
491
492        owner_of[ver_idx] = Some(pkg_idx);
493
494        if owner_pkg != pkg_idx {
495          return Err(Error::Cache(format!(
496            "version {} lies in package {}'s range but records package_index {}",
497            ver_idx, pkg_idx, owner_pkg
498          )));
499        }
500      }
501    }
502
503    for (ver_idx, owner) in owner_of.iter().enumerate() {
504      if owner.is_none() {
505        return Err(Error::Cache(format!(
506          "version {} is not covered by any package's version range",
507          ver_idx
508        )));
509      }
510    }
511
512    Ok(())
513  }
514
515  /// Checks that no full name repeats. Thunderstore guarantees full names are
516  /// unique, so a well-formed index never repeats one. Reject a corrupt or crafted
517  /// cache that does: otherwise `find_index_by_full_name` (first match) and
518  /// `build_name_index` (last match) could resolve the same name to different
519  /// packages, and the inspected package would differ from the installed one.
520  fn validate_unique_full_names(&self) -> crate::Result<()> {
521    let mut seen_full_names: HashSet<&str> = HashSet::new();
522
523    for key in self.full_names.iter().flatten() {
524      let full_name = self.interner.resolve(key);
525
526      if !seen_full_names.insert(full_name) {
527        return Err(Error::Cache(format!(
528          "duplicate full name {:?} appears in multiple packages",
529          full_name
530        )));
531      }
532    }
533
534    Ok(())
535  }
536}
537
538/// Errors with a `{field} length {actual} != {expected}` message when a parallel
539/// struct-of-arrays vector's length does not match the count its group requires.
540fn check_len(field: &str, actual: usize, expected: usize) -> crate::Result<()> {
541  if actual != expected {
542    return Err(Error::Cache(format!(
543      "{field} length {actual} != {expected}"
544    )));
545  }
546
547  Ok(())
548}
549
550/// Represents a mod package from the Thunderstore API.
551///
552/// This struct contains information about a mod package, including its metadata
553/// and all available versions.
554///
555/// `#[non_exhaustive]`: fields may be added as the Thunderstore API grows, so
556/// downstream code cannot construct or exhaustively destructure this directly.
557#[derive(Serialize, Deserialize, Clone)]
558#[non_exhaustive]
559pub struct Package {
560  /// The short name of the package
561  pub name: Option<String>,
562  /// The full name of the package, typically in format "Owner-Name"
563  pub full_name: Option<String>,
564  /// The username of the package owner
565  pub owner: Option<String>,
566  /// The URL to the package's page on Thunderstore
567  pub package_url: Option<String>,
568  /// When the package was first published
569  #[serde(with = "time::serde::rfc3339")]
570  pub date_created: time::OffsetDateTime,
571  /// When the package was last updated
572  #[serde(with = "time::serde::rfc3339")]
573  pub date_updated: time::OffsetDateTime,
574  /// Unique identifier for the package
575  pub uuid4: Option<String>,
576  /// User rating score for the package
577  pub rating_score: Option<u32>,
578  /// Whether the package is pinned by Thunderstore
579  pub is_pinned: Option<bool>,
580  /// Whether the package is marked as deprecated
581  pub is_deprecated: Option<bool>,
582  /// Whether the package contains NSFW content
583  pub has_nsfw_content: Option<bool>,
584  /// List of categories the package belongs to
585  pub categories: Vec<String>,
586  /// All available versions of the package
587  pub versions: Vec<Version>,
588}
589
590/// Represents a specific version of a mod package.
591///
592/// This struct contains information about one version of a package,
593/// including its version number, dependencies, and download information.
594///
595/// `#[non_exhaustive]`: fields may be added as the Thunderstore API grows, so
596/// downstream code cannot construct or exhaustively destructure this directly.
597#[derive(Serialize, Deserialize, Debug, Clone)]
598#[non_exhaustive]
599pub struct Version {
600  /// The name of this version
601  pub name: Option<String>,
602  /// The full name of this version
603  pub full_name: Option<String>,
604  /// The description of this version
605  pub description: Option<String>,
606  /// URL to the icon for this version
607  pub icon: Option<String>,
608  /// The version number (e.g., "1.0.0")
609  pub version_number: Option<String>,
610  /// List of dependencies required by this version
611  pub dependencies: Vec<String>,
612  /// URL to download this version
613  pub download_url: Option<String>,
614  /// Number of times this version has been downloaded
615  pub downloads: Option<u32>,
616  /// When this version was published
617  #[serde(with = "time::serde::rfc3339")]
618  pub date_created: time::OffsetDateTime,
619  /// URL to the website for this version
620  pub website_url: Option<String>,
621  /// Whether this version is active
622  pub is_active: Option<bool>,
623  /// Unique identifier for this version
624  pub uuid4: Option<String>,
625  /// Size of the download file in bytes
626  pub file_size: Option<u64>,
627}
628
629impl Package {
630  /// The most recently created version, or `None` for a package with no
631  /// versions.
632  ///
633  /// Ordering is by `date_created`, not by version number, so a republished
634  /// older version wins over a higher version string. This matches how
635  /// Thunderstore itself presents "latest".
636  pub fn latest_version(&self) -> Option<&Version> {
637    self.versions.iter().max_by_key(|v| v.date_created)
638  }
639
640  /// The `Owner-PackageName-1.0.0.zip` filename and download URL for the latest
641  /// version, or `None` if the package has no version or that version carries no
642  /// download URL.
643  ///
644  /// The filename is the archive name the download layer writes to disk, so it
645  /// must stay in the `Owner-Name-Version.zip` shape
646  /// [`crate::extract::parse_package_filename`] reads back.
647  #[cfg(test)]
648  pub fn zip_and_url(&self) -> Option<(String, String)> {
649    let pkg = self.latest_version()?;
650    let url: String = pkg.download_url.clone()?;
651    let version_number = pkg.version_number.as_ref()?;
652    let package_name = &self.full_name.as_ref()?;
653    let zip_name = format!("{}-{}.zip", package_name, version_number);
654
655    Some((zip_name, url))
656  }
657}
658
659/// Shows only the most relevant fields: the full name, and the latest version's
660/// dependencies and download URL.
661impl std::fmt::Debug for Package {
662  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
663    f.debug_struct("Package")
664      .field("full_name", &self.full_name)
665      .field(
666        "dependencies",
667        &self.latest_version().map(|v| &v.dependencies),
668      )
669      .field(
670        "download_url",
671        &self.latest_version().map(|v| &v.download_url),
672      )
673      .finish()
674  }
675}
676
677/// The outcome of [`DependencyGraph::resolve_report`]: the packages that
678/// resolved to a download, plus the names that did not.
679///
680/// `#[non_exhaustive]`: fields may be added as resolution reporting grows, so
681/// downstream code cannot construct or exhaustively destructure this directly.
682#[derive(Debug, Clone, Default)]
683#[non_exhaustive]
684pub struct Resolution {
685  /// Map of `Owner-Name-Version.zip` filename to download URL for every package
686  /// (requested or dependency) that resolved to a version in the index.
687  pub urls: HashMap<String, String>,
688  /// The `Owner-Name` identifiers that were requested (directly or as a
689  /// dependency) but not present in the index, sorted and deduplicated. A
690  /// non-empty list means the resulting install is incomplete.
691  pub unresolved: Vec<String>,
692}
693
694/// Represents a graph of package dependencies to be resolved and installed.
695///
696/// This structure helps determine which packages need to be installed,
697/// including any dependencies they require.
698#[derive(Debug, Deserialize, Serialize)]
699pub struct DependencyGraph {
700  /// The list of package names that the user wants to install
701  install_packages: Vec<String>,
702}
703
704impl DependencyGraph {
705  /// Creates a graph seeded with the packages a caller asked for.
706  ///
707  /// Names are `Owner-Name`; a trailing `-Version` is accepted but discarded
708  /// during resolution (see [`resolve`](Self::resolve)'s limitations). Nothing is
709  /// validated here, so an unknown name surfaces only at resolution time.
710  pub fn new(install_packages: Vec<String>) -> Self {
711    Self { install_packages }
712  }
713
714  /// Resolves all dependencies for the requested packages against the package index.
715  ///
716  /// This method:
717  /// 1. Starts with the user-requested packages
718  /// 2. For each package, adds its dependencies to the list to process
719  /// 3. Continues until all dependencies are resolved
720  ///
721  /// Returns a map of archive filename to download URL covering the requested
722  /// packages and their full transitive closure, ready to hand to
723  /// [`crate::client::ThunderstoreClient::download_files`].
724  ///
725  /// # Limitations
726  ///
727  /// Resolution is version-agnostic. The version suffix of a dependency string
728  /// (e.g. the `1.2.3` in `Owner-Mod-1.2.3`) is discarded and every package
729  /// resolves to its *latest* available version. There is no version-constraint
730  /// solving, so a package pinned to an older dependency will still receive the
731  /// latest one.
732  ///
733  /// A requested package or dependency that is absent from `index` is silently
734  /// skipped here; use [`resolve_report`](Self::resolve_report) to learn which
735  /// names failed to resolve so an incomplete install can be surfaced rather than
736  /// treated as success.
737  pub fn resolve(&self, index: &PackageIndex) -> HashMap<String, String> {
738    self.resolve_report(index).urls
739  }
740
741  /// Resolves the install set like [`resolve`](Self::resolve), but also reports
742  /// every requested package or transitive dependency that could not be resolved
743  /// to a download — either absent from `index`, or present but with no
744  /// downloadable version.
745  ///
746  /// A non-empty [`Resolution::unresolved`] means the returned URL map is
747  /// incomplete: those names were moderated/removed, misspelled, live in a
748  /// different community, or have no downloadable version, and installing only
749  /// what resolved would leave a broken mod. Callers that must fail (or warn) on a
750  /// missing dependency should use this instead of [`resolve`](Self::resolve),
751  /// which discards the report.
752  pub fn resolve_report(&self, index: &PackageIndex) -> Resolution {
753    let name_index = index.build_name_index();
754    let mut sorted_set: BTreeSet<String> = self.install_packages.iter().cloned().collect();
755    let mut install_indices = Vec::new();
756    let mut seen: HashSet<usize> = HashSet::new();
757    let mut unresolved: Vec<String> = Vec::new();
758
759    tracing::debug!("Starting with mod list of: {:#?}", sorted_set);
760
761    while !sorted_set.is_empty() {
762      let Some(item) = sorted_set.pop_first() else {
763        continue;
764      };
765
766      let item = crate::util::full_name_prefix(&item).to_string();
767
768      let Some(&pkg_idx) = name_index.get(&item) else {
769        tracing::warn!(
770          "requested package {:?} was not found in the index; the install will be incomplete",
771          item
772        );
773        unresolved.push(item);
774
775        continue;
776      };
777
778      // Skip packages already processed so a dependency cycle (A->B->A or a
779      // self-dependency) terminates instead of re-inserting deps forever.
780      if !seen.insert(pkg_idx) {
781        continue;
782      }
783
784      if let Some(latest_ver_idx) = index.get_latest_version_at(pkg_idx) {
785        let deps = resolve_vec(
786          &index.interner,
787          &index.versions.dependencies[latest_ver_idx],
788        );
789
790        for dep in deps {
791          tracing::debug!(
792            "Found dependency of {:#?} for {:#?} mod",
793            dep,
794            index.resolve_name_at(pkg_idx).unwrap_or_default(),
795          );
796          sorted_set.insert(dep);
797        }
798      }
799
800      install_indices.push(pkg_idx);
801    }
802
803    let mut urls = HashMap::new();
804
805    for &idx in &install_indices {
806      if let Some((zip_name, url)) = Self::package_zip_and_url(index, idx) {
807        urls.insert(zip_name, url);
808      } else if let Some(name) = index
809        .resolve_full_name_at(idx)
810        .or_else(|| index.resolve_name_at(idx))
811      {
812        // Found in the index but with no downloadable version (no versions, or a
813        // latest lacking a URL/version number): it is part of the desired set yet
814        // yields no download, so report it rather than dropping it silently.
815        tracing::warn!(
816          "package {:?} has no downloadable version; the install will be incomplete",
817          name
818        );
819        unresolved.push(name);
820      }
821    }
822
823    unresolved.sort();
824    unresolved.dedup();
825
826    Resolution { urls, unresolved }
827  }
828
829  /// Resolves one package index to its `(zip_filename, download_url)`, or `None`
830  /// when the package has no version that can be downloaded — it has no versions,
831  /// or its latest version is missing a download URL or version number.
832  fn package_zip_and_url(index: &PackageIndex, idx: usize) -> Option<(String, String)> {
833    let latest_ver_idx = index.get_latest_version_at(idx)?;
834    let url = resolve_option(
835      &index.interner,
836      index.versions.download_urls[latest_ver_idx],
837    )?;
838    let version_number = resolve_option(
839      &index.interner,
840      index.versions.version_numbers[latest_ver_idx],
841    )?;
842    let package_name = index.resolve_full_name_at(idx)?;
843    let zip_name = format!("{}-{}.zip", package_name, version_number);
844
845    Some((zip_name, url))
846  }
847}
848
849impl From<Vec<Package>> for PackageIndex {
850  fn from(packages: Vec<Package>) -> Self {
851    let num_packages = packages.len();
852    let total_versions: usize = packages.iter().map(|p| p.versions.len()).sum();
853
854    let mut interner = StringInterner::default();
855
856    let mut names = Vec::with_capacity(num_packages);
857    let mut full_names = Vec::with_capacity(num_packages);
858    let mut owners = Vec::with_capacity(num_packages);
859    let mut package_urls = Vec::with_capacity(num_packages);
860    let mut dates_created = Vec::with_capacity(num_packages);
861    let mut dates_updated = Vec::with_capacity(num_packages);
862    let mut uuid4s = Vec::with_capacity(num_packages);
863    let mut rating_scores = Vec::with_capacity(num_packages);
864    let mut is_pinned = Vec::with_capacity(num_packages);
865    let mut is_deprecated = Vec::with_capacity(num_packages);
866    let mut has_nsfw_content = Vec::with_capacity(num_packages);
867    let mut categories = Vec::with_capacity(num_packages);
868    let mut version_ranges = Vec::with_capacity(num_packages);
869
870    let mut package_indices = Vec::with_capacity(total_versions);
871    let mut version_names = Vec::with_capacity(total_versions);
872    let mut version_full_names = Vec::with_capacity(total_versions);
873    let mut version_numbers = Vec::with_capacity(total_versions);
874    let mut download_urls = Vec::with_capacity(total_versions);
875    let mut dependencies = Vec::with_capacity(total_versions);
876    let mut version_dates_created = Vec::with_capacity(total_versions);
877    let mut descriptions = Vec::with_capacity(total_versions);
878    let mut icons = Vec::with_capacity(total_versions);
879    let mut downloads = Vec::with_capacity(total_versions);
880    let mut website_urls = Vec::with_capacity(total_versions);
881    let mut is_active = Vec::with_capacity(total_versions);
882    let mut version_uuid4s = Vec::with_capacity(total_versions);
883    let mut file_sizes = Vec::with_capacity(total_versions);
884
885    let mut version_offset = 0;
886
887    for (pkg_idx, package) in packages.into_iter().enumerate() {
888      names.push(intern_option(&mut interner, package.name.as_deref()));
889      full_names.push(intern_option(&mut interner, package.full_name.as_deref()));
890      owners.push(intern_option(&mut interner, package.owner.as_deref()));
891      package_urls.push(intern_option(&mut interner, package.package_url.as_deref()));
892      dates_created.push(package.date_created);
893      dates_updated.push(package.date_updated);
894      uuid4s.push(intern_option(&mut interner, package.uuid4.as_deref()));
895      rating_scores.push(package.rating_score);
896      is_pinned.push(package.is_pinned);
897      is_deprecated.push(package.is_deprecated);
898      has_nsfw_content.push(package.has_nsfw_content);
899      categories.push(intern_vec(&mut interner, &package.categories));
900
901      let version_count = package.versions.len();
902      version_ranges.push((version_offset, version_offset + version_count));
903
904      for version in package.versions {
905        package_indices.push(pkg_idx);
906        version_names.push(intern_option(&mut interner, version.name.as_deref()));
907        version_full_names.push(intern_option(&mut interner, version.full_name.as_deref()));
908        version_numbers.push(intern_option(
909          &mut interner,
910          version.version_number.as_deref(),
911        ));
912        download_urls.push(intern_option(
913          &mut interner,
914          version.download_url.as_deref(),
915        ));
916        dependencies.push(intern_vec(&mut interner, &version.dependencies));
917        version_dates_created.push(version.date_created);
918        descriptions.push(intern_option(&mut interner, version.description.as_deref()));
919        icons.push(intern_option(&mut interner, version.icon.as_deref()));
920        downloads.push(version.downloads);
921        website_urls.push(intern_option(&mut interner, version.website_url.as_deref()));
922        is_active.push(version.is_active);
923        version_uuid4s.push(intern_option(&mut interner, version.uuid4.as_deref()));
924        file_sizes.push(version.file_size);
925      }
926
927      version_offset += version_count;
928    }
929
930    PackageIndex {
931      interner,
932      names,
933      full_names,
934      owners,
935      package_urls,
936      dates_created,
937      dates_updated,
938      uuid4s,
939      rating_scores,
940      is_pinned,
941      is_deprecated,
942      has_nsfw_content,
943      categories,
944      version_ranges,
945      versions: VersionTable {
946        package_indices,
947        names: version_names,
948        full_names: version_full_names,
949        version_numbers,
950        download_urls,
951        dependencies,
952        dates_created: version_dates_created,
953        descriptions,
954        icons,
955        downloads,
956        website_urls,
957        is_active,
958        uuid4s: version_uuid4s,
959        file_sizes,
960      },
961    }
962  }
963}
964
965/// Narrows an [`InternKey`] to the `u32` index the cache wire format stores.
966///
967/// `_interner` is unused and kept only so this mirrors the shape of its
968/// resolving counterpart.
969///
970/// # Panics
971///
972/// Panics if the index exceeds `u32::MAX`, which needs roughly four billion
973/// distinct interned strings. A silent truncation here would corrupt the string
974/// table in release builds, so this fails loudly instead.
975fn key_to_index(_interner: &StringInterner, key: InternKey) -> u32 {
976  let index = key.to_index();
977
978  // The wire format stores keys as `u32`. A checked cast turns the (practically
979  // unreachable, ~4 billion strings) overflow into a loud panic rather than a
980  // silent truncation that would corrupt the string table in release builds.
981  u32::try_from(index)
982    .unwrap_or_else(|_| panic!("intern key index {index} exceeds u32 range and would truncate"))
983}
984
985/// Converts a `u32` string-table index from a deserialized cache back into an
986/// [`InternKey`], validating it against the rebuilt interner.
987///
988/// Returns an [`Error`] (rather than panicking) when the index is out of range
989/// for the string table or otherwise not representable as a key, so a corrupt or
990/// truncated cache file surfaces as a recoverable error.
991fn decode_key(index: u32, table_len: usize) -> Result<InternKey, Error> {
992  if index as usize >= table_len {
993    return Err(Error::Cache(format!(
994      "interned string index {} out of range (string table length {})",
995      index, table_len
996    )));
997  }
998
999  InternKey::from_index(index as usize)
1000    .ok_or_else(|| Error::Cache(format!("invalid interned key index {}", index)))
1001}
1002
1003/// Encodes runtime timestamps to the cache's `i128` Unix-nanosecond wire form.
1004///
1005/// `time::OffsetDateTime` is not a native `bincode` type, so the cache stores each
1006/// timestamp as its absolute instant in nanoseconds since the Unix epoch. The
1007/// offset is not preserved, which is lossless for equality/ordering (both compare
1008/// the instant) and matches the UTC timestamps the Thunderstore API returns.
1009fn encode_dates(dates: &[OffsetDateTime]) -> Vec<i128> {
1010  dates.iter().map(|d| d.unix_timestamp_nanos()).collect()
1011}
1012
1013/// Decodes the cache's `i128` Unix-nanosecond timestamps back to `OffsetDateTime`
1014/// (as UTC), returning an [`Error`] rather than panicking if a crafted cache holds
1015/// an out-of-range value.
1016fn decode_dates(nanos: &[i128]) -> Result<Vec<OffsetDateTime>, Error> {
1017  nanos
1018    .iter()
1019    .map(|&n| {
1020      OffsetDateTime::from_unix_timestamp_nanos(n)
1021        .map_err(|e| Error::Cache(format!("invalid cached timestamp {}: {}", n, e)))
1022    })
1023    .collect()
1024}
1025
1026impl From<&PackageIndex> for SerializedPackageIndex {
1027  fn from(manifest: &PackageIndex) -> Self {
1028    let interner = &manifest.interner;
1029    let string_table: Vec<String> = interner.strings().map(|s| s.to_string()).collect();
1030
1031    // Symmetric with the `opt`/`nested` decode closures in `TryFrom`: fold the
1032    // otherwise ~14 identical key-column mappings to `u32` indices into one place.
1033    let opt = |keys: &[Option<InternKey>]| -> Vec<Option<u32>> {
1034      keys
1035        .iter()
1036        .map(|k| k.map(|key| key_to_index(interner, key)))
1037        .collect()
1038    };
1039
1040    let nested = |lists: &[Vec<InternKey>]| -> Vec<Vec<u32>> {
1041      lists
1042        .iter()
1043        .map(|keys| keys.iter().map(|k| key_to_index(interner, *k)).collect())
1044        .collect()
1045    };
1046
1047    SerializedPackageIndex {
1048      format_version: CACHE_FORMAT_VERSION,
1049      string_table,
1050      names: opt(&manifest.names),
1051      full_names: opt(&manifest.full_names),
1052      owners: opt(&manifest.owners),
1053      package_urls: opt(&manifest.package_urls),
1054      dates_created: encode_dates(&manifest.dates_created),
1055      dates_updated: encode_dates(&manifest.dates_updated),
1056      uuid4s: opt(&manifest.uuid4s),
1057      rating_scores: manifest.rating_scores.clone(),
1058      is_pinned: manifest.is_pinned.clone(),
1059      is_deprecated: manifest.is_deprecated.clone(),
1060      has_nsfw_content: manifest.has_nsfw_content.clone(),
1061      categories: nested(&manifest.categories),
1062      version_ranges: manifest.version_ranges.clone(),
1063      versions: SerializedVersionTable {
1064        package_indices: manifest.versions.package_indices.clone(),
1065        names: opt(&manifest.versions.names),
1066        full_names: opt(&manifest.versions.full_names),
1067        version_numbers: opt(&manifest.versions.version_numbers),
1068        download_urls: opt(&manifest.versions.download_urls),
1069        dependencies: nested(&manifest.versions.dependencies),
1070        dates_created: encode_dates(&manifest.versions.dates_created),
1071        descriptions: opt(&manifest.versions.descriptions),
1072        icons: opt(&manifest.versions.icons),
1073        downloads: manifest.versions.downloads.clone(),
1074        website_urls: opt(&manifest.versions.website_urls),
1075        is_active: manifest.versions.is_active.clone(),
1076        uuid4s: opt(&manifest.versions.uuid4s),
1077        file_sizes: manifest.versions.file_sizes.clone(),
1078      },
1079    }
1080  }
1081}
1082
1083impl TryFrom<SerializedPackageIndex> for PackageIndex {
1084  type Error = Error;
1085
1086  /// Rebuilds the runtime index from its serialized form, validating every
1087  /// string-table index and the structural invariants (via [`PackageIndex::validate`])
1088  /// so a corrupt or malicious cache yields an [`Error`] instead of a panic.
1089  fn try_from(manifest: SerializedPackageIndex) -> Result<Self, Self::Error> {
1090    if manifest.format_version != CACHE_FORMAT_VERSION {
1091      return Err(Error::UnsupportedCacheVersion {
1092        found: manifest.format_version,
1093        expected: CACHE_FORMAT_VERSION,
1094      });
1095    }
1096
1097    let mut interner = StringInterner::default();
1098
1099    for s in &manifest.string_table {
1100      interner.get_or_intern(s);
1101    }
1102
1103    let table_len = manifest.string_table.len();
1104
1105    // The decode below relies on each string-table index mapping 1:1 to an
1106    // interner key (index `i` -> key `i`). `get_or_intern` deduplicates, so a
1107    // table with duplicate (or otherwise non-unique) entries yields a smaller
1108    // interner; an index that passes `decode_key`'s range check would then
1109    // dangle past the interner and panic later in `resolve`. Reject such a
1110    // table here so a corrupt or malicious cache surfaces as a recoverable
1111    // error rather than a panic.
1112    if interner.len() != table_len {
1113      return Err(Error::Cache(
1114        "cache string table contains duplicate or unresolvable entries".to_string(),
1115      ));
1116    }
1117
1118    let opt = |v: &[Option<u32>]| -> Result<Vec<Option<InternKey>>, Error> {
1119      v.iter()
1120        .map(|idx| idx.map(|i| decode_key(i, table_len)).transpose())
1121        .collect()
1122    };
1123
1124    let nested = |v: &[Vec<u32>]| -> Result<Vec<Vec<InternKey>>, Error> {
1125      v.iter()
1126        .map(|indices| indices.iter().map(|&i| decode_key(i, table_len)).collect())
1127        .collect()
1128    };
1129
1130    let names = opt(&manifest.names)?;
1131    let full_names = opt(&manifest.full_names)?;
1132    let owners = opt(&manifest.owners)?;
1133    let package_urls = opt(&manifest.package_urls)?;
1134    let uuid4s = opt(&manifest.uuid4s)?;
1135    let categories = nested(&manifest.categories)?;
1136
1137    let version_names = opt(&manifest.versions.names)?;
1138    let version_full_names = opt(&manifest.versions.full_names)?;
1139    let version_numbers = opt(&manifest.versions.version_numbers)?;
1140    let download_urls = opt(&manifest.versions.download_urls)?;
1141    let dependencies = nested(&manifest.versions.dependencies)?;
1142    let descriptions = opt(&manifest.versions.descriptions)?;
1143    let icons = opt(&manifest.versions.icons)?;
1144    let website_urls = opt(&manifest.versions.website_urls)?;
1145    let version_uuid4s = opt(&manifest.versions.uuid4s)?;
1146
1147    let dates_created = decode_dates(&manifest.dates_created)?;
1148    let dates_updated = decode_dates(&manifest.dates_updated)?;
1149    let version_dates_created = decode_dates(&manifest.versions.dates_created)?;
1150
1151    let result = PackageIndex {
1152      interner,
1153      names,
1154      full_names,
1155      owners,
1156      package_urls,
1157      dates_created,
1158      dates_updated,
1159      uuid4s,
1160      rating_scores: manifest.rating_scores,
1161      is_pinned: manifest.is_pinned,
1162      is_deprecated: manifest.is_deprecated,
1163      has_nsfw_content: manifest.has_nsfw_content,
1164      categories,
1165      version_ranges: manifest.version_ranges,
1166      versions: VersionTable {
1167        package_indices: manifest.versions.package_indices,
1168        names: version_names,
1169        full_names: version_full_names,
1170        version_numbers,
1171        download_urls,
1172        dependencies,
1173        dates_created: version_dates_created,
1174        descriptions,
1175        icons,
1176        downloads: manifest.versions.downloads,
1177        website_urls,
1178        is_active: manifest.versions.is_active,
1179        uuid4s: version_uuid4s,
1180        file_sizes: manifest.versions.file_sizes,
1181      },
1182    };
1183
1184    result.validate()?;
1185
1186    Ok(result)
1187  }
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192  use super::*;
1193  use time::OffsetDateTime;
1194
1195  fn create_test_package(name: &str, owner: &str, version: &str) -> Package {
1196    let full_name = format!("{}-{}", owner, name);
1197    Package {
1198      name: Some(name.to_string()),
1199      full_name: Some(full_name.clone()),
1200      owner: Some(owner.to_string()),
1201      package_url: Some(format!("https://example.com/{}", name)),
1202      date_created: OffsetDateTime::now_utc(),
1203      date_updated: OffsetDateTime::now_utc(),
1204      uuid4: Some("test-uuid".to_string()),
1205      rating_score: Some(5),
1206      is_pinned: Some(false),
1207      is_deprecated: Some(false),
1208      has_nsfw_content: Some(false),
1209      categories: vec!["category1".to_string()],
1210      versions: vec![Version {
1211        name: Some(name.to_string()),
1212        full_name: Some(full_name.clone()),
1213        description: Some("Test description".to_string()),
1214        icon: Some("icon.png".to_string()),
1215        version_number: Some(version.to_string()),
1216        dependencies: vec![],
1217        download_url: Some(format!("https://example.com/{}/download", name)),
1218        downloads: Some(100),
1219        date_created: OffsetDateTime::now_utc(),
1220        website_url: Some("https://example.com".to_string()),
1221        is_active: Some(true),
1222        uuid4: Some("test-version-uuid".to_string()),
1223        file_size: Some(1024),
1224      }],
1225    }
1226  }
1227
1228  fn create_test_package_with_dependencies(
1229    name: &str,
1230    owner: &str,
1231    version: &str,
1232    dependencies: Vec<String>,
1233  ) -> Package {
1234    let mut pkg = create_test_package(name, owner, version);
1235    if let Some(latest_version) = pkg.versions.first_mut() {
1236      latest_version.dependencies = dependencies;
1237    }
1238    pkg
1239  }
1240
1241  #[test]
1242  fn test_latest_version() {
1243    let mut pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1244
1245    let older_version = Version {
1246      name: Some("TestMod".to_string()),
1247      full_name: Some("TestOwner-TestMod".to_string()),
1248      description: Some("Older version".to_string()),
1249      icon: Some("icon.png".to_string()),
1250      version_number: Some("0.9.0".to_string()),
1251      dependencies: vec![],
1252      download_url: Some("https://example.com/TestMod/download-old".to_string()),
1253      downloads: Some(50),
1254      date_created: OffsetDateTime::now_utc().saturating_sub(time::Duration::days(30)),
1255      website_url: Some("https://example.com".to_string()),
1256      is_active: Some(true),
1257      uuid4: Some("old-version-uuid".to_string()),
1258      file_size: Some(512),
1259    };
1260    pkg.versions.push(older_version);
1261
1262    let newer_version = Version {
1263      name: Some("TestMod".to_string()),
1264      full_name: Some("TestOwner-TestMod".to_string()),
1265      description: Some("Newer version".to_string()),
1266      icon: Some("icon.png".to_string()),
1267      version_number: Some("1.1.0".to_string()),
1268      dependencies: vec![],
1269      download_url: Some("https://example.com/TestMod/download-new".to_string()),
1270      downloads: Some(150),
1271      date_created: OffsetDateTime::now_utc().saturating_add(time::Duration::days(30)),
1272      website_url: Some("https://example.com".to_string()),
1273      is_active: Some(true),
1274      uuid4: Some("new-version-uuid".to_string()),
1275      file_size: Some(2048),
1276    };
1277    pkg.versions.push(newer_version);
1278
1279    let latest = pkg.latest_version().unwrap();
1280    assert_eq!(latest.version_number, Some("1.1.0".to_string()));
1281    assert_eq!(latest.description, Some("Newer version".to_string()));
1282  }
1283
1284  #[test]
1285  fn test_zip_and_url() {
1286    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1287    let (filename, url) = pkg.zip_and_url().unwrap();
1288
1289    assert_eq!(filename, "TestOwner-TestMod-1.0.0.zip");
1290    assert_eq!(url, "https://example.com/TestMod/download");
1291  }
1292
1293  #[test]
1294  fn test_zip_and_url_missing_data() {
1295    let mut pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1296
1297    if let Some(version) = pkg.versions.first_mut() {
1298      version.version_number = None;
1299    }
1300
1301    assert!(pkg.zip_and_url().is_none());
1302
1303    let mut pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1304
1305    if let Some(version) = pkg.versions.first_mut() {
1306      version.download_url = None;
1307    }
1308
1309    assert!(pkg.zip_and_url().is_none());
1310
1311    let mut pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1312    pkg.full_name = None;
1313
1314    assert!(pkg.zip_and_url().is_none());
1315  }
1316
1317  #[test]
1318  fn test_package_debug() {
1319    let pkg = create_test_package("ModTest", "OwnerTest", "1.2.3");
1320    let debug_output = format!("{:?}", pkg);
1321
1322    assert!(debug_output.contains("Package"));
1323    assert!(debug_output.contains("full_name: Some(\"OwnerTest-ModTest\")"));
1324    assert!(debug_output.contains("dependencies: Some([])"));
1325    assert!(
1326      debug_output.contains("download_url: Some(Some(\"https://example.com/ModTest/download\"))")
1327    );
1328
1329    let mut pkg_with_deps = create_test_package_with_dependencies(
1330      "ModWithDeps",
1331      "OwnerTest",
1332      "2.0.0",
1333      vec!["Dep1-Mod1".to_string(), "Dep2-Mod2".to_string()],
1334    );
1335    let debug_with_deps = format!("{:?}", pkg_with_deps);
1336
1337    assert!(debug_with_deps.contains("dependencies: Some([\"Dep1-Mod1\", \"Dep2-Mod2\"])"));
1338
1339    pkg_with_deps.full_name = None;
1340    let debug_missing_name = format!("{:?}", pkg_with_deps);
1341    assert!(debug_missing_name.contains("full_name: None"));
1342
1343    let mut pkg_no_version = create_test_package("NoVersion", "TestOwner", "1.0.0");
1344    pkg_no_version.versions.clear();
1345    let debug_no_version = format!("{:?}", pkg_no_version);
1346
1347    assert!(debug_no_version.contains("dependencies: None"));
1348    assert!(debug_no_version.contains("download_url: None"));
1349  }
1350
1351  #[test]
1352  fn test_package_index_basic_operations() {
1353    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1354    let packages = vec![pkg];
1355    let interned: PackageIndex = packages.into();
1356
1357    assert_eq!(interned.len(), 1);
1358
1359    assert!(!interned.is_empty());
1360
1361    assert_eq!(interned.resolve_name_at(0), Some("TestMod".to_string()));
1362
1363    assert_eq!(
1364      interned.resolve_full_name_at(0),
1365      Some("TestOwner-TestMod".to_string())
1366    );
1367
1368    assert_eq!(interned.resolve_owner_at(0), Some("TestOwner".to_string()));
1369  }
1370
1371  #[test]
1372  fn search_matches_name_and_full_name_case_insensitively() {
1373    let packages = vec![
1374      create_test_package("CoolMod", "Author", "1.0.0"),
1375      create_test_package("OtherThing", "Someone", "1.0.0"),
1376    ];
1377    let index: PackageIndex = packages.into();
1378
1379    // Matches on the short name, ignoring case.
1380    assert_eq!(index.search("coolmod"), vec![0]);
1381    assert_eq!(index.search("COOLMOD"), vec![0]);
1382    // Matches on the owner-qualified full name too.
1383    assert_eq!(index.search("author-cool"), vec![0]);
1384    // A substring anywhere counts.
1385    assert_eq!(index.search("oolMo"), vec![0]);
1386    // No match is an empty result, not an error.
1387    assert!(index.search("nothing-like-this").is_empty());
1388    // An empty term matches everything, so clearing a search box shows the full
1389    // list rather than nothing.
1390    assert_eq!(index.search(""), vec![0, 1]);
1391  }
1392
1393  #[test]
1394  fn test_package_index_borrowing_accessors() {
1395    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1396    let packages = vec![pkg];
1397    let interned: PackageIndex = packages.into();
1398
1399    // The borrowing accessors return the same values as their allocating
1400    // counterparts, and yield None for an out-of-range index.
1401    assert_eq!(interned.name_at(0), Some("TestMod"));
1402
1403    assert_eq!(interned.full_name_at(0), Some("TestOwner-TestMod"));
1404
1405    assert_eq!(interned.owner_at(0), Some("TestOwner"));
1406
1407    assert_eq!(interned.name_at(1), None);
1408
1409    assert_eq!(interned.full_name_at(1), None);
1410
1411    assert_eq!(interned.owner_at(1), None);
1412  }
1413
1414  #[test]
1415  fn test_package_index_get_package_by_full_name() {
1416    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1417    let packages = vec![pkg];
1418    let interned: PackageIndex = packages.into();
1419
1420    let found = interned.get_package_by_full_name("TestOwner-TestMod");
1421
1422    assert!(found.is_some());
1423
1424    let package = found.unwrap();
1425
1426    assert_eq!(package.name, Some("TestMod".to_string()));
1427
1428    assert_eq!(package.owner, Some("TestOwner".to_string()));
1429
1430    assert_eq!(package.versions.len(), 1);
1431  }
1432
1433  #[test]
1434  fn test_package_index_find_index_by_full_name() {
1435    let pkg1 = create_test_package("ModA", "Owner1", "1.0.0");
1436    let pkg2 = create_test_package("ModB", "Owner2", "2.0.0");
1437    let packages = vec![pkg1, pkg2];
1438    let interned: PackageIndex = packages.into();
1439
1440    assert_eq!(interned.find_index_by_full_name("Owner1-ModA"), Some(0));
1441
1442    assert_eq!(interned.find_index_by_full_name("Owner2-ModB"), Some(1));
1443
1444    assert_eq!(interned.find_index_by_full_name("NonExistent"), None);
1445  }
1446
1447  #[test]
1448  fn test_package_index_get_package_at() {
1449    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1450    let packages = vec![pkg];
1451    let interned: PackageIndex = packages.into();
1452
1453    let package = interned.get_package_at(0).unwrap();
1454
1455    assert_eq!(package.name, Some("TestMod".to_string()));
1456
1457    assert_eq!(package.full_name, Some("TestOwner-TestMod".to_string()));
1458
1459    assert_eq!(package.owner, Some("TestOwner".to_string()));
1460
1461    assert_eq!(package.versions.len(), 1);
1462
1463    assert_eq!(
1464      package.versions[0].version_number,
1465      Some("1.0.0".to_string())
1466    );
1467  }
1468
1469  #[test]
1470  fn test_package_index_get_latest_version_at() {
1471    let mut pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1472
1473    let older_version = Version {
1474      name: Some("TestMod".to_string()),
1475      full_name: Some("TestOwner-TestMod".to_string()),
1476      description: Some("Older version".to_string()),
1477      icon: Some("icon.png".to_string()),
1478      version_number: Some("0.9.0".to_string()),
1479      dependencies: vec![],
1480      download_url: Some("https://example.com/TestMod/download-old".to_string()),
1481      downloads: Some(50),
1482      date_created: OffsetDateTime::now_utc().saturating_sub(time::Duration::days(30)),
1483      website_url: Some("https://example.com".to_string()),
1484      is_active: Some(true),
1485      uuid4: Some("old-version-uuid".to_string()),
1486      file_size: Some(512),
1487    };
1488    pkg.versions.insert(0, older_version);
1489
1490    let packages = vec![pkg];
1491    let interned: PackageIndex = packages.into();
1492
1493    let latest_idx = interned.get_latest_version_at(0);
1494
1495    assert!(latest_idx.is_some());
1496
1497    let version_idx = latest_idx.unwrap();
1498    let version_number = interned.versions.version_numbers[version_idx]
1499      .map(|key| interned.interner.resolve(&key).to_string());
1500
1501    assert_eq!(version_number, Some("1.0.0".to_string()));
1502  }
1503
1504  #[test]
1505  fn test_package_index_get_latest_version_at_empty_range() {
1506    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1507    let packages = vec![pkg];
1508    let mut interned: PackageIndex = packages.into();
1509
1510    interned.version_ranges[0] = (0, 0);
1511
1512    let latest = interned.get_latest_version_at(0);
1513
1514    assert!(latest.is_none());
1515  }
1516
1517  #[test]
1518  fn test_package_index_build_name_index() {
1519    let pkg1 = create_test_package("ModA", "Owner1", "1.0.0");
1520    let pkg2 = create_test_package("ModB", "Owner2", "2.0.0");
1521    let packages = vec![pkg1, pkg2];
1522    let interned: PackageIndex = packages.into();
1523
1524    let index = interned.build_name_index();
1525
1526    assert_eq!(index.len(), 2);
1527
1528    assert_eq!(index.get("Owner1-ModA"), Some(&0));
1529
1530    assert_eq!(index.get("Owner2-ModB"), Some(&1));
1531  }
1532
1533  #[test]
1534  fn test_package_index_validate_valid() {
1535    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1536    let packages = vec![pkg];
1537    let interned: PackageIndex = packages.into();
1538
1539    let result = interned.validate();
1540
1541    assert!(result.is_ok());
1542  }
1543
1544  #[test]
1545  fn validate_split_methods_are_individually_callable() {
1546    let interned: PackageIndex = vec![create_test_package("ModA", "Owner1", "1.0.0")].into();
1547
1548    // All four checks pass on a well-formed index.
1549    assert!(interned.validate_lengths().is_ok());
1550    assert!(interned.validate_version_ranges().is_ok());
1551    assert!(interned.validate_version_ownership().is_ok());
1552    assert!(interned.validate_unique_full_names().is_ok());
1553
1554    // A parallel-vector length mismatch is caught specifically by validate_lengths.
1555    let mut broken: PackageIndex = vec![create_test_package("ModA", "Owner1", "1.0.0")].into();
1556    broken.owners.pop();
1557    assert!(broken.validate_lengths().is_err());
1558
1559    // A repeated full name is caught specifically by validate_unique_full_names.
1560    let dup: PackageIndex = vec![
1561      create_test_package("ModA", "Owner1", "1.0.0"),
1562      create_test_package("ModA", "Owner1", "2.0.0"),
1563    ]
1564    .into();
1565    assert!(dup.validate_unique_full_names().is_err());
1566  }
1567
1568  #[test]
1569  fn encode_decode_dates_roundtrips_and_rejects_out_of_range() {
1570    let dates = vec![
1571      OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(),
1572      OffsetDateTime::from_unix_timestamp(0).unwrap(),
1573    ];
1574
1575    let encoded = encode_dates(&dates);
1576    let decoded = decode_dates(&encoded).unwrap();
1577
1578    assert_eq!(decoded, dates);
1579
1580    // An i128 nanosecond value beyond the representable range is a recoverable
1581    // error, not a panic.
1582    let err = decode_dates(&[i128::MAX]).unwrap_err();
1583
1584    assert!(matches!(err, Error::Cache(_)));
1585  }
1586
1587  #[test]
1588  fn from_packages_populates_core_fields() {
1589    let interned: PackageIndex = vec![create_test_package("ModA", "Owner1", "1.0.0")].into();
1590
1591    assert_eq!(interned.len(), 1);
1592    assert_eq!(
1593      interned.resolve_full_name_at(0),
1594      Some("Owner1-ModA".to_string())
1595    );
1596
1597    // A single package with one version yields one range covering that version,
1598    // and the version records package index 0 as its owner.
1599    assert_eq!(interned.version_ranges, vec![(0, 1)]);
1600    assert_eq!(interned.versions.package_indices, vec![0]);
1601  }
1602
1603  #[test]
1604  fn test_package_index_validate_mismatched_full_names_length() {
1605    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1606    let packages = vec![pkg];
1607    let mut interned: PackageIndex = packages.into();
1608
1609    interned.full_names.pop();
1610
1611    let result = interned.validate();
1612
1613    assert!(result.is_err());
1614
1615    assert!(
1616      result
1617        .unwrap_err()
1618        .to_string()
1619        .contains("full_names length")
1620    );
1621  }
1622
1623  #[test]
1624  fn test_package_index_validate_mismatched_owners_length() {
1625    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1626    let packages = vec![pkg];
1627    let mut interned: PackageIndex = packages.into();
1628
1629    interned.owners.pop();
1630
1631    let result = interned.validate();
1632
1633    assert!(result.is_err());
1634
1635    assert!(result.unwrap_err().to_string().contains("owners length"));
1636  }
1637
1638  #[test]
1639  fn test_package_index_validate_mismatched_package_urls_length() {
1640    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1641    let packages = vec![pkg];
1642    let mut interned: PackageIndex = packages.into();
1643
1644    interned.package_urls.pop();
1645
1646    let result = interned.validate();
1647
1648    assert!(result.is_err());
1649
1650    assert!(
1651      result
1652        .unwrap_err()
1653        .to_string()
1654        .contains("package_urls length")
1655    );
1656  }
1657
1658  #[test]
1659  fn test_package_index_validate_mismatched_dates_created_length() {
1660    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1661    let packages = vec![pkg];
1662    let mut interned: PackageIndex = packages.into();
1663
1664    interned.dates_created.pop();
1665
1666    let result = interned.validate();
1667
1668    assert!(result.is_err());
1669
1670    assert!(
1671      result
1672        .unwrap_err()
1673        .to_string()
1674        .contains("dates_created length")
1675    );
1676  }
1677
1678  #[test]
1679  fn test_package_index_validate_mismatched_dates_updated_length() {
1680    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1681    let packages = vec![pkg];
1682    let mut interned: PackageIndex = packages.into();
1683
1684    interned.dates_updated.pop();
1685
1686    let result = interned.validate();
1687
1688    assert!(result.is_err());
1689
1690    assert!(
1691      result
1692        .unwrap_err()
1693        .to_string()
1694        .contains("dates_updated length")
1695    );
1696  }
1697
1698  #[test]
1699  fn test_package_index_validate_mismatched_uuid4s_length() {
1700    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1701    let packages = vec![pkg];
1702    let mut interned: PackageIndex = packages.into();
1703
1704    interned.uuid4s.pop();
1705
1706    let result = interned.validate();
1707
1708    assert!(result.is_err());
1709
1710    assert!(result.unwrap_err().to_string().contains("uuid4s length"));
1711  }
1712
1713  #[test]
1714  fn test_package_index_validate_mismatched_rating_scores_length() {
1715    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1716    let packages = vec![pkg];
1717    let mut interned: PackageIndex = packages.into();
1718
1719    interned.rating_scores.pop();
1720
1721    let result = interned.validate();
1722
1723    assert!(result.is_err());
1724
1725    assert!(
1726      result
1727        .unwrap_err()
1728        .to_string()
1729        .contains("rating_scores length")
1730    );
1731  }
1732
1733  #[test]
1734  fn test_package_index_validate_mismatched_is_pinned_length() {
1735    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1736    let packages = vec![pkg];
1737    let mut interned: PackageIndex = packages.into();
1738
1739    interned.is_pinned.pop();
1740
1741    let result = interned.validate();
1742
1743    assert!(result.is_err());
1744
1745    assert!(result.unwrap_err().to_string().contains("is_pinned length"));
1746  }
1747
1748  #[test]
1749  fn test_package_index_validate_mismatched_is_deprecated_length() {
1750    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1751    let packages = vec![pkg];
1752    let mut interned: PackageIndex = packages.into();
1753
1754    interned.is_deprecated.pop();
1755
1756    let result = interned.validate();
1757
1758    assert!(result.is_err());
1759
1760    assert!(
1761      result
1762        .unwrap_err()
1763        .to_string()
1764        .contains("is_deprecated length")
1765    );
1766  }
1767
1768  #[test]
1769  fn test_package_index_validate_mismatched_has_nsfw_content_length() {
1770    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1771    let packages = vec![pkg];
1772    let mut interned: PackageIndex = packages.into();
1773
1774    interned.has_nsfw_content.pop();
1775
1776    let result = interned.validate();
1777
1778    assert!(result.is_err());
1779
1780    assert!(
1781      result
1782        .unwrap_err()
1783        .to_string()
1784        .contains("has_nsfw_content length")
1785    );
1786  }
1787
1788  #[test]
1789  fn test_package_index_validate_mismatched_categories_length() {
1790    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1791    let packages = vec![pkg];
1792    let mut interned: PackageIndex = packages.into();
1793
1794    interned.categories.pop();
1795
1796    let result = interned.validate();
1797
1798    assert!(result.is_err());
1799
1800    assert!(
1801      result
1802        .unwrap_err()
1803        .to_string()
1804        .contains("categories length")
1805    );
1806  }
1807
1808  #[test]
1809  fn test_package_index_validate_mismatched_version_ranges_length() {
1810    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1811    let packages = vec![pkg];
1812    let mut interned: PackageIndex = packages.into();
1813
1814    interned.version_ranges.pop();
1815
1816    let result = interned.validate();
1817
1818    assert!(result.is_err());
1819
1820    assert!(
1821      result
1822        .unwrap_err()
1823        .to_string()
1824        .contains("version_ranges length")
1825    );
1826  }
1827
1828  #[test]
1829  fn test_package_index_validate_mismatched_version_numbers_length() {
1830    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1831    let packages = vec![pkg];
1832    let mut interned: PackageIndex = packages.into();
1833
1834    interned.versions.version_numbers.pop();
1835
1836    let result = interned.validate();
1837
1838    assert!(result.is_err());
1839
1840    assert!(
1841      result
1842        .unwrap_err()
1843        .to_string()
1844        .contains("version_numbers length")
1845    );
1846  }
1847
1848  #[test]
1849  fn test_package_index_validate_mismatched_version_download_urls_length() {
1850    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1851    let packages = vec![pkg];
1852    let mut interned: PackageIndex = packages.into();
1853
1854    interned.versions.download_urls.pop();
1855
1856    let result = interned.validate();
1857
1858    assert!(result.is_err());
1859
1860    assert!(
1861      result
1862        .unwrap_err()
1863        .to_string()
1864        .contains("download_urls length")
1865    );
1866  }
1867
1868  #[test]
1869  fn test_package_index_validate_mismatched_version_dependencies_length() {
1870    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1871    let packages = vec![pkg];
1872    let mut interned: PackageIndex = packages.into();
1873
1874    interned.versions.dependencies.pop();
1875
1876    let result = interned.validate();
1877
1878    assert!(result.is_err());
1879
1880    assert!(
1881      result
1882        .unwrap_err()
1883        .to_string()
1884        .contains("dependencies length")
1885    );
1886  }
1887
1888  #[test]
1889  fn test_package_index_validate_mismatched_version_dates_created_length() {
1890    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1891    let packages = vec![pkg];
1892    let mut interned: PackageIndex = packages.into();
1893
1894    interned.versions.dates_created.pop();
1895
1896    let result = interned.validate();
1897
1898    assert!(result.is_err());
1899
1900    assert!(
1901      result
1902        .unwrap_err()
1903        .to_string()
1904        .contains("dates_created length")
1905    );
1906  }
1907
1908  #[test]
1909  fn test_package_index_validate_mismatched_version_descriptions_length() {
1910    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1911    let packages = vec![pkg];
1912    let mut interned: PackageIndex = packages.into();
1913
1914    interned.versions.descriptions.pop();
1915
1916    let result = interned.validate();
1917
1918    assert!(result.is_err());
1919
1920    assert!(
1921      result
1922        .unwrap_err()
1923        .to_string()
1924        .contains("descriptions length")
1925    );
1926  }
1927
1928  #[test]
1929  fn test_package_index_validate_mismatched_version_icons_length() {
1930    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1931    let packages = vec![pkg];
1932    let mut interned: PackageIndex = packages.into();
1933
1934    interned.versions.icons.pop();
1935
1936    let result = interned.validate();
1937
1938    assert!(result.is_err());
1939
1940    assert!(result.unwrap_err().to_string().contains("icons length"));
1941  }
1942
1943  #[test]
1944  fn test_package_index_validate_mismatched_version_downloads_length() {
1945    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1946    let packages = vec![pkg];
1947    let mut interned: PackageIndex = packages.into();
1948
1949    interned.versions.downloads.pop();
1950
1951    let result = interned.validate();
1952
1953    assert!(result.is_err());
1954
1955    assert!(result.unwrap_err().to_string().contains("downloads length"));
1956  }
1957
1958  #[test]
1959  fn test_package_index_validate_mismatched_version_website_urls_length() {
1960    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1961    let packages = vec![pkg];
1962    let mut interned: PackageIndex = packages.into();
1963
1964    interned.versions.website_urls.pop();
1965
1966    let result = interned.validate();
1967
1968    assert!(result.is_err());
1969
1970    assert!(
1971      result
1972        .unwrap_err()
1973        .to_string()
1974        .contains("website_urls length")
1975    );
1976  }
1977
1978  #[test]
1979  fn test_package_index_validate_mismatched_version_is_active_length() {
1980    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1981    let packages = vec![pkg];
1982    let mut interned: PackageIndex = packages.into();
1983
1984    interned.versions.is_active.pop();
1985
1986    let result = interned.validate();
1987
1988    assert!(result.is_err());
1989
1990    assert!(result.unwrap_err().to_string().contains("is_active length"));
1991  }
1992
1993  #[test]
1994  fn test_package_index_validate_mismatched_version_uuid4s_length() {
1995    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
1996    let packages = vec![pkg];
1997    let mut interned: PackageIndex = packages.into();
1998
1999    interned.versions.uuid4s.pop();
2000
2001    let result = interned.validate();
2002
2003    assert!(result.is_err());
2004
2005    assert!(result.unwrap_err().to_string().contains("uuid4s length"));
2006  }
2007
2008  #[test]
2009  fn test_package_index_validate_mismatched_version_file_sizes_length() {
2010    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
2011    let packages = vec![pkg];
2012    let mut interned: PackageIndex = packages.into();
2013
2014    interned.versions.file_sizes.pop();
2015
2016    let result = interned.validate();
2017
2018    assert!(result.is_err());
2019
2020    assert!(
2021      result
2022        .unwrap_err()
2023        .to_string()
2024        .contains("file_sizes length")
2025    );
2026  }
2027
2028  #[test]
2029  fn test_package_index_validate_invalid_version_range() {
2030    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
2031    let packages = vec![pkg];
2032    let mut interned: PackageIndex = packages.into();
2033
2034    interned.version_ranges[0] = (5, 3);
2035
2036    let result = interned.validate();
2037
2038    assert!(result.is_err());
2039
2040    assert!(
2041      result
2042        .unwrap_err()
2043        .to_string()
2044        .contains("Invalid version range")
2045    );
2046  }
2047
2048  #[test]
2049  fn test_package_index_validate_version_range_out_of_bounds() {
2050    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
2051    let packages = vec![pkg];
2052    let mut interned: PackageIndex = packages.into();
2053
2054    interned.version_ranges[0] = (0, 999);
2055
2056    let result = interned.validate();
2057
2058    assert!(result.is_err());
2059
2060    assert!(result.unwrap_err().to_string().contains("ends at"));
2061  }
2062
2063  #[test]
2064  fn test_package_index_serialization_round_trip() {
2065    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
2066    let packages = vec![pkg.clone()];
2067    let interned: PackageIndex = packages.into();
2068
2069    let serializable: SerializedPackageIndex = (&interned).into();
2070
2071    let binary = bincode::encode_to_vec(&serializable, bincode::config::standard()).unwrap();
2072
2073    let (deserialized, _): (SerializedPackageIndex, usize) =
2074      bincode::decode_from_slice(&binary, bincode::config::standard()).unwrap();
2075
2076    let recovered: PackageIndex = deserialized.try_into().unwrap();
2077
2078    assert_eq!(recovered.len(), 1);
2079
2080    assert_eq!(recovered.resolve_name_at(0), Some("TestMod".to_string()));
2081
2082    assert_eq!(
2083      recovered.resolve_full_name_at(0),
2084      Some("TestOwner-TestMod".to_string())
2085    );
2086
2087    let recovered_pkg = recovered.get_package_at(0).unwrap();
2088
2089    assert_eq!(recovered_pkg.name, pkg.name);
2090
2091    assert_eq!(recovered_pkg.full_name, pkg.full_name);
2092
2093    assert_eq!(recovered_pkg.versions.len(), pkg.versions.len());
2094  }
2095
2096  #[test]
2097  fn test_version_name_and_full_name_are_version_level_not_package_level() {
2098    // Real Thunderstore data: a version's full_name carries the version suffix,
2099    // distinct from the package's own full_name.
2100    let mut pkg = create_test_package("CoolMod", "Author", "1.0.0");
2101    pkg.name = Some("CoolMod".to_string());
2102    pkg.full_name = Some("Author-CoolMod".to_string());
2103    pkg.versions[0].name = Some("Author-CoolMod".to_string());
2104    pkg.versions[0].full_name = Some("Author-CoolMod-1.0.0".to_string());
2105
2106    let interned: PackageIndex = vec![pkg].into();
2107
2108    // Materialized directly, the version keeps its own name/full_name rather than
2109    // inheriting the package's.
2110    let materialized = interned.get_package_at(0).unwrap();
2111
2112    assert_eq!(materialized.full_name, Some("Author-CoolMod".to_string()));
2113
2114    assert_eq!(
2115      materialized.versions[0].full_name,
2116      Some("Author-CoolMod-1.0.0".to_string())
2117    );
2118
2119    assert_eq!(
2120      materialized.versions[0].name,
2121      Some("Author-CoolMod".to_string())
2122    );
2123
2124    // The distinction also survives a serialize -> deserialize round trip.
2125    let serializable: SerializedPackageIndex = (&interned).into();
2126
2127    let binary = bincode::encode_to_vec(&serializable, bincode::config::standard()).unwrap();
2128
2129    let (deserialized, _): (SerializedPackageIndex, usize) =
2130      bincode::decode_from_slice(&binary, bincode::config::standard()).unwrap();
2131
2132    let recovered: PackageIndex = deserialized.try_into().unwrap();
2133    let recovered_pkg = recovered.get_package_at(0).unwrap();
2134
2135    assert_eq!(recovered_pkg.full_name, Some("Author-CoolMod".to_string()));
2136
2137    assert_eq!(
2138      recovered_pkg.versions[0].full_name,
2139      Some("Author-CoolMod-1.0.0".to_string())
2140    );
2141  }
2142
2143  #[test]
2144  fn test_resolve_report_lists_unresolved_packages() {
2145    let interned: PackageIndex = vec![create_test_package("ModA", "Owner1", "1.0.0")].into();
2146
2147    let graph = DependencyGraph::new(vec!["Owner1-ModA".to_string(), "Ghost-Missing".to_string()]);
2148
2149    let report = graph.resolve_report(&interned);
2150
2151    // The present package resolves; the missing one is reported rather than
2152    // silently dropped.
2153    assert_eq!(report.urls.len(), 1);
2154    assert_eq!(report.unresolved, vec!["Ghost-Missing".to_string()]);
2155
2156    // The convenience `resolve` still returns just the URL map.
2157    assert_eq!(graph.resolve(&interned).len(), 1);
2158  }
2159
2160  #[test]
2161  fn test_resolve_report_flags_package_with_no_downloadable_version() {
2162    let mut pkg = create_test_package("ModA", "Owner1", "1.0.0");
2163
2164    // Present in the index, but with nothing downloadable.
2165    pkg.versions.clear();
2166
2167    let interned: PackageIndex = vec![pkg].into();
2168
2169    let graph = DependencyGraph::new(vec!["Owner1-ModA".to_string()]);
2170    let report = graph.resolve_report(&interned);
2171
2172    // It yields no URL, and rather than being silently dropped it is reported so
2173    // an empty `unresolved` can be trusted to mean a complete install.
2174    assert!(report.urls.is_empty());
2175    assert_eq!(report.unresolved, vec!["Owner1-ModA".to_string()]);
2176  }
2177
2178  #[test]
2179  fn test_duplicate_string_table_returns_error_not_panic() {
2180    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
2181    let interned: PackageIndex = vec![pkg].into();
2182    let mut serializable: SerializedPackageIndex = (&interned).into();
2183
2184    // Push a duplicate string into the table and repoint a package name at its
2185    // (now out-of-real-range) index. The index passes `decode_key`'s range check
2186    // but dangles past the deduplicated interner; without the duplicate-table
2187    // guard this would panic later in `resolve` instead of erroring here.
2188    let dup = serializable.string_table[0].clone();
2189
2190    serializable.string_table.push(dup);
2191
2192    let last_index = serializable.string_table.len() as u32 - 1;
2193
2194    serializable.names[0] = Some(last_index);
2195
2196    let result = PackageIndex::try_from(serializable);
2197
2198    assert!(result.is_err());
2199  }
2200
2201  #[test]
2202  fn test_corrupt_cache_index_returns_error_not_panic() {
2203    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
2204    let interned: PackageIndex = vec![pkg].into();
2205    let mut serializable: SerializedPackageIndex = (&interned).into();
2206
2207    // Point a package name at a string-table index that does not exist; a
2208    // corrupt cache must surface as an error rather than panicking.
2209    let bad_index = serializable.string_table.len() as u32 + 5;
2210    serializable.names[0] = Some(bad_index);
2211
2212    let result = PackageIndex::try_from(serializable);
2213
2214    assert!(result.is_err());
2215  }
2216
2217  #[test]
2218  fn test_package_index_dependency_graph_resolve() {
2219    let pkg1 = create_test_package("ModA", "Owner1", "1.0.0");
2220    let pkg2 = create_test_package_with_dependencies(
2221      "ModB",
2222      "Owner2",
2223      "2.0.0",
2224      vec!["Owner3-ModC".to_string()],
2225    );
2226    let pkg3 = create_test_package_with_dependencies(
2227      "ModC",
2228      "Owner3",
2229      "1.5.0",
2230      vec!["Owner4-ModD".to_string()],
2231    );
2232    let pkg4 = create_test_package("ModD", "Owner4", "0.9.0");
2233
2234    let packages = vec![pkg1, pkg2, pkg3, pkg4];
2235    let interned: PackageIndex = packages.into();
2236
2237    let dg1 = DependencyGraph::new(vec!["Owner1-ModA".to_string()]);
2238    let result1 = dg1.resolve(&interned);
2239
2240    assert_eq!(result1.len(), 1);
2241
2242    assert!(result1.contains_key("Owner1-ModA-1.0.0.zip"));
2243
2244    let dg2 = DependencyGraph::new(vec!["Owner2-ModB".to_string()]);
2245    let result2 = dg2.resolve(&interned);
2246
2247    assert_eq!(result2.len(), 3);
2248
2249    assert!(result2.contains_key("Owner2-ModB-2.0.0.zip"));
2250
2251    assert!(result2.contains_key("Owner3-ModC-1.5.0.zip"));
2252
2253    assert!(result2.contains_key("Owner4-ModD-0.9.0.zip"));
2254  }
2255
2256  #[test]
2257  fn test_package_index_dependency_graph_resolve_with_missing_dependency() {
2258    let pkg1 = create_test_package("ModA", "Owner1", "1.0.0");
2259    let pkg2 = create_test_package_with_dependencies(
2260      "ModB",
2261      "Owner2",
2262      "2.0.0",
2263      vec!["Owner3-NonExistent".to_string()],
2264    );
2265
2266    let packages = vec![pkg1, pkg2];
2267    let interned: PackageIndex = packages.into();
2268
2269    let dg = DependencyGraph::new(vec!["Owner2-ModB".to_string()]);
2270    let result = dg.resolve(&interned);
2271
2272    assert_eq!(result.len(), 1);
2273
2274    assert!(result.contains_key("Owner2-ModB-2.0.0.zip"));
2275  }
2276
2277  #[test]
2278  fn test_package_index_dependency_graph_resolve_with_version_suffix() {
2279    let pkg1 = create_test_package("ModA", "Owner1", "1.0.0");
2280
2281    let packages = vec![pkg1];
2282    let interned: PackageIndex = packages.into();
2283
2284    let dg = DependencyGraph::new(vec!["Owner1-ModA-1.0.0".to_string()]);
2285    let result = dg.resolve(&interned);
2286
2287    assert_eq!(result.len(), 1);
2288
2289    assert!(result.contains_key("Owner1-ModA-1.0.0.zip"));
2290  }
2291
2292  #[test]
2293  fn test_cache_rejects_unsupported_format_version() {
2294    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
2295    let interned: PackageIndex = vec![pkg].into();
2296    let mut serializable: SerializedPackageIndex = (&interned).into();
2297
2298    // A cache written by a future/incompatible format must be rejected as a
2299    // recoverable error (so the client re-fetches) rather than misdecoded.
2300    serializable.format_version = CACHE_FORMAT_VERSION + 1;
2301
2302    let result = PackageIndex::try_from(serializable);
2303
2304    assert!(matches!(result, Err(Error::UnsupportedCacheVersion { .. })));
2305  }
2306
2307  #[test]
2308  fn test_cache_current_format_version_round_trips() {
2309    let pkg = create_test_package("TestMod", "TestOwner", "1.0.0");
2310    let interned: PackageIndex = vec![pkg].into();
2311    let serializable: SerializedPackageIndex = (&interned).into();
2312
2313    assert_eq!(serializable.format_version, CACHE_FORMAT_VERSION);
2314    assert!(PackageIndex::try_from(serializable).is_ok());
2315  }
2316
2317  #[test]
2318  fn test_validate_rejects_version_owned_by_wrong_package() {
2319    let pkg1 = create_test_package("ModA", "Owner1", "1.0.0");
2320    let pkg2 = create_test_package("ModB", "Owner2", "2.0.0");
2321    let mut interned: PackageIndex = vec![pkg1, pkg2].into();
2322
2323    // Version 0 sits in package 0's range but claims to belong to package 1.
2324    interned.versions.package_indices[0] = 1;
2325
2326    let result = interned.validate();
2327
2328    assert!(result.is_err());
2329
2330    assert!(result.unwrap_err().to_string().contains("package_index"));
2331  }
2332
2333  #[test]
2334  fn test_validate_rejects_overlapping_version_ranges() {
2335    let pkg1 = create_test_package("ModA", "Owner1", "1.0.0");
2336    let pkg2 = create_test_package("ModB", "Owner2", "2.0.0");
2337    let mut interned: PackageIndex = vec![pkg1, pkg2].into();
2338
2339    // Point both packages at the same version, leaving version 1 uncovered.
2340    interned.version_ranges[1] = (0, 1);
2341
2342    let result = interned.validate();
2343
2344    assert!(result.is_err());
2345
2346    assert!(result.unwrap_err().to_string().contains("overlapping"));
2347  }
2348
2349  #[test]
2350  fn test_package_index_validate_rejects_duplicate_full_names() {
2351    // Two packages sharing a full name: Thunderstore guarantees uniqueness, so a
2352    // cache with a duplicate is corrupt. Rejecting it keeps
2353    // `find_index_by_full_name` (first match) and `build_name_index` (last match)
2354    // from resolving the same name to different packages.
2355    let pkg1 = create_test_package("TestMod", "TestOwner", "1.0.0");
2356    let pkg2 = create_test_package("TestMod", "TestOwner", "2.0.0");
2357    let interned: PackageIndex = vec![pkg1, pkg2].into();
2358
2359    let result = interned.validate();
2360
2361    assert!(result.is_err());
2362
2363    assert!(
2364      result
2365        .unwrap_err()
2366        .to_string()
2367        .contains("duplicate full name")
2368    );
2369  }
2370
2371  #[test]
2372  fn test_package_index_dependency_graph_resolve_handles_cycles() {
2373    // ModA <-> ModB depend on each other, and ModC depends on itself. Without a
2374    // visited set, resolve() would re-insert these deps forever and never return.
2375    let pkg_a = create_test_package_with_dependencies(
2376      "ModA",
2377      "Owner1",
2378      "1.0.0",
2379      vec!["Owner2-ModB".to_string()],
2380    );
2381    let pkg_b = create_test_package_with_dependencies(
2382      "ModB",
2383      "Owner2",
2384      "2.0.0",
2385      vec!["Owner1-ModA".to_string()],
2386    );
2387    let pkg_c = create_test_package_with_dependencies(
2388      "ModC",
2389      "Owner3",
2390      "3.0.0",
2391      vec!["Owner3-ModC".to_string()],
2392    );
2393
2394    let packages = vec![pkg_a, pkg_b, pkg_c];
2395    let interned: PackageIndex = packages.into();
2396
2397    let dg = DependencyGraph::new(vec!["Owner1-ModA".to_string(), "Owner3-ModC".to_string()]);
2398    let result = dg.resolve(&interned);
2399
2400    assert_eq!(result.len(), 3);
2401
2402    assert!(result.contains_key("Owner1-ModA-1.0.0.zip"));
2403
2404    assert!(result.contains_key("Owner2-ModB-2.0.0.zip"));
2405
2406    assert!(result.contains_key("Owner3-ModC-3.0.0.zip"));
2407  }
2408}