Skip to main content

modde_sources/
meta.rs

1//! `.meta` sidecar files that record per-download progress and Nexus metadata
2//! so paused or interrupted downloads can be resumed.
3
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result};
7use modde_core::{NexusFileId, NexusModId};
8use serde::{Deserialize, Serialize};
9
10/// JSON sidecar file stored alongside a download (e.g. `mod_file.zip.meta`).
11///
12/// Tracks download progress and Nexus metadata so downloads can be resumed
13/// after a crash or pause.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct DownloadMeta {
16    pub url: String,
17    #[serde(default)]
18    pub expected_hash: Option<u64>,
19    #[serde(default)]
20    pub bytes_downloaded: u64,
21    #[serde(default)]
22    pub total_bytes: Option<u64>,
23    #[serde(default)]
24    pub nexus_mod_id: Option<NexusModId>,
25    #[serde(default)]
26    pub nexus_file_id: Option<NexusFileId>,
27    #[serde(default)]
28    pub game_domain: Option<String>,
29    #[serde(default)]
30    pub mod_name: Option<String>,
31    #[serde(default)]
32    pub version: Option<String>,
33    #[serde(default = "default_status")]
34    pub status: String,
35}
36
37fn default_status() -> String {
38    "queued".to_string()
39}
40
41impl DownloadMeta {
42    /// Load a `.meta` sidecar from disk.
43    pub fn load(path: &Path) -> Result<Self> {
44        let data =
45            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
46        serde_json::from_str(&data).with_context(|| format!("parsing {}", path.display()))
47    }
48
49    /// Persist this meta to a `.meta` sidecar on disk.
50    pub fn save(&self, path: &Path) -> Result<()> {
51        let data = serde_json::to_string_pretty(self)?;
52        if let Some(parent) = path.parent() {
53            std::fs::create_dir_all(parent)?;
54        }
55        std::fs::write(path, data).with_context(|| format!("writing {}", path.display()))
56    }
57}
58
59/// Derive the `.meta` sidecar path for a given download path.
60///
61/// E.g. `/downloads/mod.zip` → `/downloads/mod.zip.meta`
62#[must_use]
63pub fn meta_path(download_path: &Path) -> PathBuf {
64    let mut p = download_path.as_os_str().to_owned();
65    p.push(".meta");
66    PathBuf::from(p)
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn test_meta_roundtrip() {
75        let dir = tempfile::tempdir().unwrap();
76        let mp = dir.path().join("test.zip.meta");
77
78        let meta = DownloadMeta {
79            url: "https://example.com/mod.zip".into(),
80            expected_hash: Some(0xDEAD_BEEF),
81            bytes_downloaded: 1024,
82            total_bytes: Some(4096),
83            nexus_mod_id: Some(NexusModId::from(42)),
84            nexus_file_id: Some(NexusFileId::from(99)),
85            game_domain: Some("skyrimspecialedition".into()),
86            mod_name: Some("Cool Mod".into()),
87            version: Some("1.2.3".into()),
88            status: "downloading".into(),
89        };
90
91        meta.save(&mp).unwrap();
92        let loaded = DownloadMeta::load(&mp).unwrap();
93
94        assert_eq!(loaded.url, meta.url);
95        assert_eq!(loaded.expected_hash, meta.expected_hash);
96        assert_eq!(loaded.bytes_downloaded, meta.bytes_downloaded);
97        assert_eq!(loaded.total_bytes, meta.total_bytes);
98        assert_eq!(loaded.nexus_mod_id, meta.nexus_mod_id);
99        assert_eq!(loaded.nexus_file_id, meta.nexus_file_id);
100        assert_eq!(loaded.game_domain, meta.game_domain);
101        assert_eq!(loaded.mod_name, meta.mod_name);
102        assert_eq!(loaded.version, meta.version);
103        assert_eq!(loaded.status, meta.status);
104    }
105
106    #[test]
107    fn test_meta_path() {
108        let p = meta_path(Path::new("/downloads/mod.zip"));
109        assert_eq!(p, PathBuf::from("/downloads/mod.zip.meta"));
110    }
111
112    #[test]
113    fn test_meta_defaults() {
114        let json = r#"{"url": "https://example.com/f.zip"}"#;
115        let meta: DownloadMeta = serde_json::from_str(json).unwrap();
116        assert_eq!(meta.bytes_downloaded, 0);
117        assert_eq!(meta.total_bytes, None);
118        assert_eq!(meta.expected_hash, None);
119        assert_eq!(meta.status, "queued");
120    }
121}