1use std::{collections::HashMap, fmt::Display, ops::Deref, path::PathBuf, time::Duration};
2
3use reqwest::Url;
4use serde::{
5 Deserialize, Serialize, Serializer,
6 de::{self, Visitor},
7};
8use time::{OffsetDateTime, UtcDateTime};
9
10#[macro_export]
11macro_rules! nexus_joiner {
12 ($ver:expr, $components:expr) => {{
13 let mut url = reqwest::Url::parse("https://api.nexusmods.com")
14 .expect("Could not parse URL (very fatal!)")
15 .join(&format!("{}/", $ver))
16 .expect("Could not join version!");
17 let mut it = $components.into_iter().peekable();
18 while let Some(comp) = it.next() {
19 if it.peek().is_none() {
20 url = url
21 .join(&format!("{}.json", comp))
22 .expect("Could not join {comp}");
23 } else {
24 url = url
25 .join(&format!("{}/", comp))
26 .expect("Could not join {comp}");
27 }
28 }
29 url
30 }};
31}
32
33#[derive(Clone, Copy)]
34pub enum Limited {
35 Hourly,
36 Daily,
37}
38
39#[derive(Clone, Copy)]
40pub struct RateLimiting {
41 pub(crate) hourly_limit: u16,
43 pub(crate) hourly_remaining: u16,
44 pub(crate) hourly_reset: OffsetDateTime,
45
46 pub(crate) daily_limit: u16,
47 pub(crate) daily_remaining: u16,
48 pub(crate) daily_reset: OffsetDateTime,
49}
50
51impl RateLimiting {
52 pub const fn limit(&self, limit: Limited) -> u16 {
53 match limit {
54 Limited::Hourly => self.hourly_limit,
55 Limited::Daily => self.daily_limit,
56 }
57 }
58
59 pub const fn remaining(&self, limit: Limited) -> u16 {
60 match limit {
61 Limited::Hourly => self.hourly_remaining,
62 Limited::Daily => self.daily_remaining,
63 }
64 }
65
66 pub const fn reset(&self, limit: Limited) -> UtcDateTime {
67 match limit {
68 Limited::Hourly => self.hourly_reset.to_utc(),
69 Limited::Daily => self.daily_reset.to_utc(),
70 }
71 }
72}
73
74#[derive(Debug, Serialize, Deserialize)]
76pub struct Validate {
77 user_id: usize,
78 key: String,
79 name: String,
80 #[serde(alias = "is_premium?")]
81 is_premium_q: bool,
82 #[serde(alias = "is_supporter?")]
83 is_supporter_q: bool,
84 email: String,
85 profile_url: Url,
86 is_premium: bool,
87 is_supporter: bool,
88}
89
90impl Validate {
91 pub const fn is_premium(&self) -> bool {
93 self.is_premium_q && self.is_premium
95 }
96
97 pub const fn is_supporter(&self) -> bool {
102 self.is_supporter_q && self.is_supporter
104 }
105
106 pub fn email(&self) -> &str {
107 &self.email
108 }
109
110 pub fn name(&self) -> &str {
111 &self.name
112 }
113
114 pub fn url(&self) -> &Url {
119 &self.profile_url
120 }
121}
122
123#[derive(Debug, Serialize, Deserialize)]
124pub struct ModEntry {
125 mod_id: ModId,
126 domain_name: String,
127}
128
129impl ModEntry {
130 pub const fn id(&self) -> ModId {
131 self.mod_id
132 }
133
134 pub fn domain_name(&self) -> &str {
135 &self.domain_name
136 }
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
144#[serde(transparent)]
145pub struct ModId {
146 id: u64,
147}
148
149impl ModId {
150 pub const fn id(&self) -> u64 {
152 self.id
153 }
154
155 pub(crate) const fn from_u64(id: u64) -> Self {
158 Self { id }
159 }
160}
161
162impl PartialEq<u64> for ModId {
163 fn eq(&self, other: &u64) -> bool {
164 self.id() == *other
165 }
166}
167
168impl Display for ModId {
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 write!(f, "{}", self.id)
171 }
172}
173
174impl From<ModEntry> for ModId {
175 fn from(value: ModEntry) -> Self {
176 value.mod_id
177 }
178}
179
180#[derive(Debug, Serialize, Deserialize)]
182#[serde(transparent)]
183pub struct TrackedModsRaw {
184 mods: Vec<ModEntry>,
185}
186
187impl TrackedModsRaw {
188 pub fn mods(&self) -> &[ModEntry] {
189 &self.mods
190 }
191}
192
193impl From<TrackedModsRaw> for TrackedMods {
194 fn from(value: TrackedModsRaw) -> Self {
195 let mut mods: HashMap<String, Vec<ModId>> = HashMap::with_capacity(value.mods.len());
196 for entry in value.mods {
197 mods.entry(entry.domain_name)
198 .or_default()
199 .push(entry.mod_id);
200 }
201 Self { mods }
202 }
203}
204
205#[derive(Debug)]
207pub struct TrackedMods {
208 mods: HashMap<String, Vec<ModId>>,
209}
210
211impl TrackedMods {
212 pub fn get_game(&self, name: &str) -> Option<&[ModId]> {
214 self.mods.get(name).map(|v| &**v)
215 }
216
217 pub fn games(&self) -> impl Iterator<Item = &str> {
219 self.mods.keys().map(String::as_str)
220 }
221}
222
223impl IntoIterator for TrackedMods {
224 type Item = (String, Vec<ModId>);
225 type IntoIter = std::collections::hash_map::IntoIter<String, Vec<ModId>>;
226
227 fn into_iter(self) -> Self::IntoIter {
228 self.mods.into_iter()
229 }
230}
231
232#[derive(Debug, Serialize, Deserialize)]
233#[serde(transparent)]
234pub struct Endorsements {
235 mods: Vec<Endorsement>,
236}
237
238impl IntoIterator for Endorsements {
239 type Item = Endorsement;
240 type IntoIter = std::vec::IntoIter<Self::Item>;
241
242 fn into_iter(self) -> Self::IntoIter {
243 self.mods.into_iter()
244 }
245}
246
247impl Endorsements {
248 pub fn find<F>(&self, func: F) -> Option<&Endorsement>
249 where
250 F: Fn(&Endorsement) -> bool,
251 {
252 self.mods.iter().find(|e| func(e))
253 }
254}
255
256#[derive(Debug, Serialize, Deserialize)]
257pub struct Endorsement {
258 mod_id: ModId,
259 domain_name: String,
260 #[serde(with = "time::serde::iso8601")]
261 date: OffsetDateTime,
262 version: Option<String>,
263 status: EndorseStatus,
264}
265
266impl Endorsement {
267 pub const fn id(&self) -> ModId {
268 self.mod_id
269 }
270
271 pub fn domain_name(&self) -> &str {
272 &self.domain_name
273 }
274
275 pub fn version(&self) -> Option<&str> {
276 self.version.as_deref()
277 }
278
279 pub const fn date(&self) -> UtcDateTime {
280 self.date.to_utc()
281 }
282
283 pub const fn endorsed_status(&self) -> EndorseStatus {
284 self.status
285 }
286
287 pub const fn is_endorsed(&self) -> bool {
288 matches!(self.status, EndorseStatus::Endorsed)
289 }
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293pub enum EndorseStatus {
294 Endorsed,
295 #[serde(untagged)]
296 NotEndorsed,
297}
298
299#[derive(Debug, Serialize, Deserialize)]
300pub struct GameId {
301 id: u64,
302 name: String,
303 forum_url: Url,
304 nexusmods_url: Url,
305 genre: String,
306 file_count: u64,
307 domain_name: String,
308 #[serde(with = "time::serde::timestamp")]
309 approved_date: OffsetDateTime,
310 file_views: u64,
311 authors: u64,
312 file_endorsements: u64,
313 mods: u64,
314 categories: Vec<GameCategory>,
315}
316
317impl GameId {
318 pub const fn id(&self) -> u64 {
319 self.id
320 }
321
322 pub fn pretty_name(&self) -> &str {
323 &self.name
324 }
325
326 pub fn forum(&self) -> &Url {
327 &self.forum_url
328 }
329
330 pub fn page(&self) -> &Url {
331 &self.nexusmods_url
332 }
333
334 pub fn genre(&self) -> &str {
335 &self.genre
336 }
337
338 pub fn domain_name(&self) -> &str {
339 &self.domain_name
340 }
341
342 pub const fn approved_date(&self) -> UtcDateTime {
343 self.approved_date.to_utc()
344 }
345
346 pub const fn file_views(&self) -> u64 {
347 self.file_views
348 }
349
350 pub const fn authors(&self) -> u64 {
351 self.authors
352 }
353
354 pub const fn endorsements(&self) -> u64 {
355 self.file_endorsements
356 }
357
358 pub const fn mods(&self) -> u64 {
359 self.mods
360 }
361
362 pub fn categories(&self) -> &[GameCategory] {
363 &self.categories
364 }
365
366 pub fn trace_parent_category(&self, category: &GameCategory) -> Option<&GameCategory> {
368 let id = &category.parent_category;
369 self.categories.iter().find(|cat| match id {
370 Category::Category(n) => *n == cat.category_id,
371 Category::None => false,
372 })
373 }
374}
375
376#[derive(Debug, Serialize, Deserialize)]
377pub struct GameCategory {
378 category_id: u64,
379 name: String,
380 parent_category: Category,
381}
382
383#[derive(Debug)]
384pub enum Category {
385 Category(u64),
386 None,
387}
388
389impl<'de> Deserialize<'de> for Category {
390 fn deserialize<D>(de: D) -> Result<Self, D::Error>
391 where
392 D: serde::Deserializer<'de>,
393 {
394 struct CategoryVisitor;
395
396 impl<'de> Visitor<'de> for CategoryVisitor {
397 type Value = Category;
398
399 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
400 write!(f, "a number or false")
401 }
402
403 fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
404 where
405 E: serde::de::Error,
406 {
407 Ok(Category::Category(v))
408 }
409
410 fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
411 where
412 E: serde::de::Error,
413 {
414 if v.is_negative() {
415 Err(de::Error::custom("negative number not allowed"))
416 } else {
417 Ok(Category::Category(v as u64))
418 }
419 }
420
421 fn visit_bool<E>(self, v: bool) -> Result<Self::Value, E>
422 where
423 E: de::Error,
424 {
425 if !v {
426 Ok(Category::None)
427 } else {
428 Err(de::Error::custom("`true` not allowed"))
429 }
430 }
431 }
432
433 de.deserialize_any(CategoryVisitor)
434 }
435}
436
437impl Serialize for Category {
438 fn serialize<S>(&self, se: S) -> Result<S::Ok, S::Error>
439 where
440 S: serde::Serializer,
441 {
442 match *self {
443 Self::Category(n) => se.serialize_u64(n),
444 Self::None => se.serialize_bool(false),
445 }
446 }
447}
448
449#[derive(Debug, Serialize, Deserialize)]
450pub struct ModFiles {
451 files: Vec<ModFile>,
452 file_updates: Vec<FileUpdate>,
453}
454
455impl ModFiles {
456 pub fn iter_files(&self) -> impl Iterator<Item = &ModFile> {
457 self.files.iter()
458 }
459
460 pub fn iter_updates(&self) -> impl Iterator<Item = &FileUpdate> {
461 self.file_updates.iter()
462 }
463
464 pub fn into_iter_files(self) -> impl IntoIterator<Item = ModFile> {
465 self.files.into_iter()
466 }
467
468 pub fn into_iter_updates(self) -> impl IntoIterator<Item = FileUpdate> {
469 self.file_updates.into_iter()
470 }
471
472 pub fn dedup<F>(&self, same: F) -> Vec<ModFile>
478 where
479 F: Fn(&ModFile, &ModFile) -> bool,
480 {
481 let mut out = vec![];
482
483 'outer: for x in &self.files {
484 for y in &out {
485 if same(x, y) {
486 continue 'outer;
487 }
488 }
489 out.push(x.clone());
490 }
491
492 out
493 }
494}
495
496#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct ModFile {
498 id: Vec<u64>,
499 uid: u64,
500 file_id: u64,
501 name: String,
502 version: String,
503 category_id: u64,
504 category_name: CategoryName,
505 is_primary: bool,
506 size: u64,
507 file_name: String,
508 #[serde(with = "time::serde::timestamp")]
509 uploaded_timestamp: OffsetDateTime,
510 #[serde(with = "time::serde::iso8601")]
511 uploaded_time: OffsetDateTime,
512 mod_version: String,
513 external_virus_scan_url: Option<Url>,
514 description: Option<String>,
515 size_kb: u64,
516 size_in_bytes: u64,
517 changelog_html: Option<String>,
518 content_preview_link: Url,
519}
520
521impl ModFile {
522 pub fn ids(&self) -> &[u64] {
523 &self.id
524 }
525
526 pub const fn uid(&self) -> u64 {
527 self.uid
528 }
529
530 pub const fn file_id(&self) -> u64 {
531 self.file_id
532 }
533
534 pub fn name(&self) -> &str {
535 &self.name
536 }
537
538 pub fn version(&self) -> &str {
539 &self.version
540 }
541
542 pub const fn category_id(&self) -> u64 {
543 self.category_id
544 }
545
546 pub const fn category_name(&self) -> CategoryName {
547 self.category_name
548 }
549
550 pub const fn is_primary(&self) -> bool {
551 self.is_primary
552 }
553
554 pub const fn size(&self) -> u64 {
556 self.size
557 }
558
559 pub fn file_name(&self) -> &str {
560 &self.file_name
561 }
562
563 pub const fn uploaded_at(&self) -> UtcDateTime {
564 self.uploaded_timestamp.to_utc()
565 }
566
567 pub fn mod_version(&self) -> &str {
568 &self.mod_version
569 }
570
571 pub fn virus_scan_url(&self) -> Option<&Url> {
572 self.external_virus_scan_url.as_ref()
573 }
574
575 pub fn description(&self) -> Option<&str> {
576 self.description.as_deref()
577 }
578
579 pub const fn size_kb(&self) -> u64 {
580 self.size_kb
581 }
582
583 pub const fn size_bytes(&self) -> u64 {
584 self.size_in_bytes
585 }
586
587 pub fn changelog(&self) -> Option<&str> {
588 self.changelog_html.as_deref()
589 }
590
591 pub fn content_preview(&self) -> &Url {
592 &self.content_preview_link
593 }
594}
595
596#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
597#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
598pub enum CategoryName {
599 Main,
600 Update,
601 Optional,
602 OldVersion,
603 Miscellaneous,
604 Archived,
605}
606
607impl CategoryName {
608 pub(crate) const fn to_header_str(self) -> &'static str {
609 match self {
610 Self::Main => "main",
611 Self::Update => "update",
612 Self::Optional => "optional",
613 Self::OldVersion => "old_version",
614 Self::Miscellaneous => "miscellaneous",
615 Self::Archived => "archived",
616 }
617 }
618}
619
620#[derive(Debug, Serialize, Deserialize)]
621pub struct FileUpdate {
622 old_file_id: u64,
623 new_file_id: u64,
624 old_file_name: String,
625 new_file_name: String,
626 #[serde(with = "time::serde::timestamp")]
627 uploaded_timestamp: OffsetDateTime,
628 #[serde(with = "time::serde::iso8601")]
629 uploaded_time: OffsetDateTime,
630}
631
632impl FileUpdate {
633 pub const fn ids(&self) -> (u64, u64) {
635 (self.old_file_id, self.new_file_id)
636 }
637
638 pub fn names(&self) -> (&str, &str) {
640 (&self.old_file_name, &self.new_file_name)
641 }
642
643 pub const fn uploaded_at(&self) -> UtcDateTime {
644 self.uploaded_timestamp.to_utc()
645 }
646}
647
648#[derive(Debug, Serialize, Deserialize)]
649pub struct PreviewFileRoot {
650 children: Vec<PreviewFileChildren>,
651}
652
653#[derive(Debug, Serialize, Deserialize)]
654#[serde(tag = "type")]
655pub enum PreviewFileChildren {
656 #[serde(rename = "directory")]
657 Directory {
658 path: String,
659 name: String,
660 children: Vec<PreviewFileChildren>,
661 },
662 #[serde(rename = "file")]
663 File {
664 path: String,
665 name: String,
666 size: String,
667 },
668}
669
670impl PreviewFileChildren {
671 pub fn into_pathbuf(self) -> PathBuf {
672 match self {
673 Self::File { path, .. } | Self::Directory { path, .. } => PathBuf::from(path),
674 }
675 }
676}
677
678impl PreviewFileRoot {
679 pub fn files(&self) -> Vec<&PreviewFileChildren> {
681 fn gather<'a>(node: &'a PreviewFileChildren, out: &mut Vec<&'a PreviewFileChildren>) {
682 match node {
683 PreviewFileChildren::Directory { children, .. } => {
684 for child in children {
685 gather(child, out);
686 }
687 }
688 PreviewFileChildren::File { .. } => {
689 out.push(node);
690 }
691 }
692 }
693
694 let mut out = vec![];
695
696 for child in &self.children {
697 gather(child, &mut out);
698 }
699
700 out
701 }
702}
703
704#[derive(Debug, Serialize, Deserialize)]
705pub struct ModUpdated {
706 mod_id: ModId,
707 #[serde(with = "time::serde::timestamp")]
708 latest_file_update: OffsetDateTime,
709 #[serde(with = "time::serde::timestamp")]
710 latest_mod_activity: OffsetDateTime,
711}
712
713impl ModUpdated {
714 pub const fn id(&self) -> ModId {
715 self.mod_id
716 }
717
718 pub const fn last_updated(&self) -> UtcDateTime {
719 self.latest_file_update.to_utc()
720 }
721
722 pub const fn last_activity(&self) -> UtcDateTime {
723 self.latest_mod_activity.to_utc()
724 }
725}
726
727#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
728pub enum TimePeriod {
729 Day,
730 Week,
731 Month,
732}
733
734impl TimePeriod {
735 pub(crate) const fn as_str(&self) -> &str {
736 match self {
737 Self::Day => "1d",
738 Self::Week => "1w",
739 Self::Month => "1m",
740 }
741 }
742}
743
744#[allow(clippy::from_over_into)]
745impl Into<Duration> for TimePeriod {
746 fn into(self) -> Duration {
747 match self {
748 Self::Day => Duration::from_hours(24),
749 Self::Week => Duration::from_hours(24 * 7),
750 Self::Month => Duration::from_hours(24 * 7 * 31),
751 }
752 }
753}
754
755#[derive(Debug, Serialize, Deserialize)]
756#[serde(transparent)]
757pub struct Changelog {
758 logs: HashMap<String, Vec<String>>,
759}
760
761impl Deref for Changelog {
762 type Target = HashMap<String, Vec<String>>;
763
764 fn deref(&self) -> &Self::Target {
765 &self.logs
766 }
767}
768
769#[derive(Debug, Serialize, Deserialize)]
770pub struct GameMod {
771 name: String,
772 summary: String,
773 description: String,
774 picture_url: Url,
775 mod_downloads: u64,
776 mod_unique_downloads: u64,
777 uid: u64,
778 game_id: u64,
779 allow_rating: bool,
780 domain_name: String,
781 category_id: u64,
782 version: String,
783 endorsement_count: u64,
784 #[serde(with = "time::serde::timestamp")]
785 created_timestamp: OffsetDateTime,
786 #[serde(with = "time::serde::iso8601")]
787 created_time: OffsetDateTime,
788 #[serde(with = "time::serde::timestamp")]
789 updated_timestamp: OffsetDateTime,
790 #[serde(with = "time::serde::iso8601")]
791 updated_time: OffsetDateTime,
792 author: String,
793 uploaded_by: String,
794 uploaded_users_profile_url: Url,
795 contains_adult_content: bool,
796 status: String,
798 available: bool,
799 #[serde(skip)]
800 user: (),
801 endorsement: EndorsementInfo,
802}
803
804impl GameMod {
805 pub fn name(&self) -> &str {
806 &self.name
807 }
808
809 pub fn summary(&self) -> &str {
810 &self.summary
811 }
812
813 pub fn description(&self) -> &str {
814 &self.description
815 }
816
817 pub const fn mod_picture(&self) -> &Url {
818 &self.picture_url
819 }
820
821 pub const fn unique_downloads(&self) -> u64 {
822 self.mod_unique_downloads
823 }
824
825 pub const fn uid(&self) -> u64 {
826 self.uid
827 }
828
829 pub const fn game_id(&self) -> u64 {
830 self.game_id
831 }
832
833 pub const fn allow_rating(&self) -> bool {
834 self.allow_rating
835 }
836
837 pub fn domain_name(&self) -> &str {
838 &self.domain_name
839 }
840
841 pub const fn category_id(&self) -> u64 {
842 self.category_id
843 }
844
845 pub fn version(&self) -> &str {
846 &self.version
847 }
848
849 pub const fn endorsements(&self) -> u64 {
850 self.endorsement_count
851 }
852
853 pub const fn created_at(&self) -> UtcDateTime {
854 self.created_timestamp.to_utc()
855 }
856
857 pub const fn updated_at(&self) -> UtcDateTime {
858 self.updated_timestamp.to_utc()
859 }
860
861 pub fn author(&self) -> &str {
862 &self.author
863 }
864
865 pub fn uploaded_by(&self) -> &str {
866 &self.uploaded_by
867 }
868
869 pub const fn uploaded_by_profile_url(&self) -> &Url {
870 &self.uploaded_users_profile_url
871 }
872
873 pub const fn adult_content(&self) -> bool {
874 self.contains_adult_content
875 }
876
877 pub const fn available(&self) -> bool {
878 self.available
879 }
880
881 pub const fn endorsement(&self) -> &EndorsementInfo {
882 &self.endorsement
883 }
884}
885
886#[derive(Debug, Clone, Serialize, Deserialize)]
887pub struct EndorsementInfo {
888 endorse_status: HasEndorsed,
889 #[serde(serialize_with = "ts::serialize")]
890 #[serde(deserialize_with = "ts::deserialize")]
891 timestamp: Option<OffsetDateTime>,
892 version: Option<String>,
893}
894
895impl EndorsementInfo {
896 pub const fn status(&self) -> HasEndorsed {
897 self.endorse_status
898 }
899
900 pub const fn has_endorsed(&self) -> bool {
901 matches!(self.endorse_status, HasEndorsed::Endorsed)
902 }
903
904 pub const fn endorsed_at(&self) -> Option<OffsetDateTime> {
905 self.timestamp
906 }
907
908 pub fn endorsed_version(&self) -> Option<&str> {
909 self.version.as_deref()
910 }
911}
912
913#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
914pub enum HasEndorsed {
915 Endorsed,
916 Undecided,
917}
918
919mod ts {
920 use serde::{Deserialize, Deserializer, Serializer};
921 use time::OffsetDateTime;
922
923 pub fn serialize<S>(value: &Option<OffsetDateTime>, s: S) -> Result<S::Ok, S::Error>
924 where
925 S: Serializer,
926 {
927 match value {
928 Some(v) => s.serialize_i64(v.unix_timestamp()),
929 None => s.serialize_none(),
930 }
931 }
932
933 pub fn deserialize<'de, D>(d: D) -> Result<Option<OffsetDateTime>, D::Error>
934 where
935 D: Deserializer<'de>,
936 {
937 let opt = Option::<i64>::deserialize(d)?;
938 Ok(opt.map(|secs| OffsetDateTime::from_unix_timestamp(secs).unwrap()))
939 }
940}