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