cyclone_mod/
request.rs

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    // Limited to 2,500 requests per 24 hours.
42    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/// Validation object for a given user.
75#[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    /// Is the user a premium user?
92    pub const fn is_premium(&self) -> bool {
93        // I think?
94        self.is_premium_q && self.is_premium
95    }
96
97    /// Is the user a supporter?
98    ///
99    /// In order for this to be `true`, the user must've bought premium at any point in time, even
100    /// if they currently do not have it.
101    pub const fn is_supporter(&self) -> bool {
102        // I think?
103        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    /// URL to the user's NexusMods' avatar.
115    ///
116    /// # Warning
117    /// This is *not* the path to the user's home page!
118    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/// A checked and verified-to-exist mod ID.
140///
141/// A thin wrapper for a `u64`, but everywhere that you see [`ModId`], you can assume
142/// that it is a valid mod ID, as opposed to a random number which may or may not exist.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
144#[serde(transparent)]
145pub struct ModId {
146    id: u64,
147}
148
149impl ModId {
150    /// Get the underlying `u64`.
151    pub const fn id(&self) -> u64 {
152        self.id
153    }
154
155    /// Secret teehee. Use this internally when you verify that a given u64 actually is a valid
156    /// [`ModId`].
157    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/// You may find this to be very tedious to work with. Consider [`TrackedMods`] instead.
181#[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/// A collection of game names and tracked mod IDs.
206#[derive(Debug)]
207pub struct TrackedMods {
208    mods: HashMap<String, Vec<ModId>>,
209}
210
211impl TrackedMods {
212    /// Get a list of [`ModId`]s from a game name.
213    pub fn get_game(&self, name: &str) -> Option<&[ModId]> {
214        self.mods.get(name).map(|v| &**v)
215    }
216
217    /// Get all game names.
218    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    /// Get the parent category for a given category.
367    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    /// Deduplicate entries based on a condition.
473    ///
474    /// Mostly useful for when you want to just get a single throwaway instance of [`ModFile`],
475    /// likely for printing out something pertaining to the mod as a whole, rather than every file
476    /// located inside it, such as a loop to print what names of mods the user endorses.
477    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    /// Appears to be in kilobytes.
555    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    /// Return the old and new ID.
634    pub const fn ids(&self) -> (u64, u64) {
635        (self.old_file_id, self.new_file_id)
636    }
637
638    /// Return the old and new names.
639    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    /// Get all the files in the preview.
680    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    // TODO: Make this an enum probably
797    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}