Skip to main content

modde_sources/nexus/
updates.rs

1//! Checking installed mods against the Nexus "recently updated" feed to find
2//! available updates.
3
4use std::collections::HashMap;
5
6use anyhow::Result;
7use modde_core::NexusModId;
8use serde::{Deserialize, Serialize};
9use tracing::{debug, info};
10
11use super::api::NexusApi;
12
13/// Information about a mod that has an available update on Nexus.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ModUpdate {
16    /// The `mod_id` string used in the profile (local identifier).
17    pub mod_id: String,
18    /// Nexus mod ID.
19    pub nexus_mod_id: NexusModId,
20    /// Currently installed version (if known).
21    pub installed_version: Option<String>,
22    /// Timestamp when the mod was installed locally.
23    pub installed_timestamp: i64,
24    /// Timestamp of the latest file update on Nexus.
25    pub latest_file_update: u64,
26    /// Timestamp of the latest mod activity on Nexus.
27    pub latest_mod_activity: u64,
28}
29
30/// A mod tracked for update checking.
31#[derive(Debug, Clone)]
32pub struct TrackedMod {
33    pub mod_id: String,
34    pub nexus_mod_id: NexusModId,
35    pub nexus_game_domain: String,
36    pub installed_version: Option<String>,
37    pub installed_timestamp: i64,
38}
39
40/// Check for updates for a set of tracked mods.
41///
42/// Groups mods by game domain, calls `updated_mods` once per domain,
43/// then cross-references against installed timestamps.
44pub async fn check_updates(
45    api: &NexusApi,
46    tracked: &[TrackedMod],
47    period: &str,
48) -> Result<Vec<ModUpdate>> {
49    if tracked.is_empty() {
50        return Ok(Vec::new());
51    }
52
53    // Group tracked mods by game domain
54    let mut by_domain: HashMap<&str, Vec<&TrackedMod>> = HashMap::new();
55    for t in tracked {
56        by_domain
57            .entry(t.nexus_game_domain.as_str())
58            .or_default()
59            .push(t);
60    }
61
62    let mut updates = Vec::new();
63
64    for (domain, domain_mods) in &by_domain {
65        debug!(
66            domain,
67            mod_count = domain_mods.len(),
68            "checking updates for domain"
69        );
70
71        // Build a lookup: nexus_mod_id -> TrackedMod
72        let lookup: HashMap<NexusModId, &&TrackedMod> =
73            domain_mods.iter().map(|m| (m.nexus_mod_id, m)).collect();
74
75        // One API call per domain
76        let updated = api.updated_mods(domain, period).await?;
77
78        for nexus_mod in &updated {
79            if let Some(tracked_mod) = lookup.get(&nexus_mod.mod_id) {
80                // Mod is in our tracked list — check if update is newer
81                let install_ts = tracked_mod.installed_timestamp as u64;
82                if nexus_mod.latest_file_update > install_ts {
83                    updates.push(ModUpdate {
84                        mod_id: tracked_mod.mod_id.clone(),
85                        nexus_mod_id: nexus_mod.mod_id,
86                        installed_version: tracked_mod.installed_version.clone(),
87                        installed_timestamp: tracked_mod.installed_timestamp,
88                        latest_file_update: nexus_mod.latest_file_update,
89                        latest_mod_activity: nexus_mod.latest_mod_activity,
90                    });
91                }
92            }
93        }
94    }
95
96    if updates.is_empty() {
97        info!("all tracked mods are up to date");
98    } else {
99        info!(count = updates.len(), "found mods with available updates");
100    }
101
102    Ok(updates)
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn test_tracked_mod_grouping() {
111        let tracked = vec![
112            TrackedMod {
113                mod_id: "skyui".to_string(),
114                nexus_mod_id: NexusModId::from(12604),
115                nexus_game_domain: "skyrimspecialedition".to_string(),
116                installed_version: Some("5.2".to_string()),
117                installed_timestamp: 1700000000,
118            },
119            TrackedMod {
120                mod_id: "ussep".to_string(),
121                nexus_mod_id: NexusModId::from(266),
122                nexus_game_domain: "skyrimspecialedition".to_string(),
123                installed_version: Some("4.2.8".to_string()),
124                installed_timestamp: 1700000000,
125            },
126            TrackedMod {
127                mod_id: "fo4_patch".to_string(),
128                nexus_mod_id: NexusModId::from(4598),
129                nexus_game_domain: "fallout4".to_string(),
130                installed_version: None,
131                installed_timestamp: 1700000000,
132            },
133        ];
134
135        let mut by_domain: HashMap<&str, Vec<&TrackedMod>> = HashMap::new();
136        for t in &tracked {
137            by_domain
138                .entry(t.nexus_game_domain.as_str())
139                .or_default()
140                .push(t);
141        }
142
143        assert_eq!(by_domain.len(), 2);
144        assert_eq!(by_domain["skyrimspecialedition"].len(), 2);
145        assert_eq!(by_domain["fallout4"].len(), 1);
146    }
147}