Skip to main content

wows_data_mgr/
builds.rs

1//! Master builds index (`builds.toml`) and per-build metadata.
2//!
3//! The builds index lives at `{dump_base}/builds.toml` and tracks all dumped
4//! game versions. Per-build metadata lives in `{build_dir}/metadata.toml` and
5//! includes file hashes for content-addressed storage management.
6
7use std::collections::BTreeMap;
8use std::path::Path;
9
10use serde::Deserialize;
11use serde::Serialize;
12
13// -- Master builds index (builds.toml) --
14
15/// Top-level index of all dumped builds.
16#[derive(Debug, Default, Serialize, Deserialize)]
17pub struct BuildsIndex {
18    #[serde(default)]
19    pub builds: Vec<BuildEntry>,
20}
21
22/// A single dumped build entry.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct BuildEntry {
25    pub version: String,
26    pub build: u32,
27    pub dir: String,
28    pub dumped_at: String,
29}
30
31impl BuildsIndex {
32    /// Load from disk. Returns an empty index if the file doesn't exist.
33    pub fn load(path: &Path) -> Self {
34        std::fs::read_to_string(path).ok().and_then(|s| toml::from_str(&s).ok()).unwrap_or_default()
35    }
36
37    /// Save to disk. Uses write-to-temp-then-rename for atomicity.
38    pub fn save(&self, path: &Path) -> Result<(), rootcause::Report> {
39        use rootcause::prelude::*;
40        let contents = toml::to_string_pretty(self).attach_with(|| "Failed to serialize builds.toml")?;
41        if let Some(parent) = path.parent() {
42            std::fs::create_dir_all(parent)
43                .attach_with(|| format!("Failed to create directory {}", parent.display()))?;
44        }
45        let tmp = path.with_extension("toml.tmp");
46        std::fs::write(&tmp, &contents).attach_with(|| format!("Failed to write {}", tmp.display()))?;
47        std::fs::rename(&tmp, path)
48            .attach_with(|| format!("Failed to rename {} to {}", tmp.display(), path.display()))?;
49        Ok(())
50    }
51
52    /// Add or update an entry. If a build with the same number exists, it's replaced.
53    pub fn upsert(&mut self, entry: BuildEntry) {
54        if let Some(existing) = self.builds.iter_mut().find(|e| e.build == entry.build) {
55            *existing = entry;
56        } else {
57            self.builds.push(entry);
58        }
59        self.builds.sort_by_key(|e| e.build);
60    }
61
62    /// Remove a build entry. Returns the removed entry if found.
63    pub fn remove_build(&mut self, build: u32) -> Option<BuildEntry> {
64        let idx = self.builds.iter().position(|e| e.build == build)?;
65        Some(self.builds.remove(idx))
66    }
67
68    /// Find an entry by exact build number.
69    pub fn find_by_build(&self, build: u32) -> Option<&BuildEntry> {
70        self.builds.iter().find(|e| e.build == build)
71    }
72
73    /// Find all entries matching a version prefix.
74    /// e.g. "15.2.0" matches all builds with that version, regardless of build number.
75    pub fn find_by_version(&self, version_query: &str) -> Vec<&BuildEntry> {
76        self.builds.iter().filter(|e| crate::manifest::version_matches(&e.version, version_query)).collect()
77    }
78
79    /// Resolve a build number to a dump entry.
80    ///
81    /// 1. Try exact build match
82    /// 2. If no exact match and `target_version` is provided, find builds with
83    ///    the same `major.minor.patch` and pick the closest build number
84    ///
85    /// Returns `(entry, is_exact_match)`.
86    pub fn resolve_build(&self, target_build: u32, target_version: Option<&str>) -> Option<(&BuildEntry, bool)> {
87        // Exact match
88        if let Some(entry) = self.find_by_build(target_build) {
89            return Some((entry, true));
90        }
91
92        // Version-based fallback
93        if let Some(version) = target_version {
94            let candidates = self.find_by_version(version);
95            if !candidates.is_empty() {
96                let closest =
97                    candidates.iter().min_by_key(|e| (e.build as i64 - target_build as i64).unsigned_abs()).unwrap();
98                return Some((closest, false));
99            }
100        }
101
102        None
103    }
104}
105
106// -- Per-build metadata (metadata.toml) --
107
108/// Enhanced per-build metadata with file hashes for CAS management.
109#[derive(Debug, Default, Serialize, Deserialize)]
110pub struct BuildMetadata {
111    pub version: String,
112    pub build: u32,
113    /// VFS file path -> CAS hash. Only present in new-format dumps.
114    #[serde(default)]
115    pub files: BTreeMap<String, String>,
116    /// Build-relative path -> CAS hash for derived artifacts (the rkyv game
117    /// params blob and the compressed copies fetched by web clients). Kept
118    /// separate from `files`, which tracks the extracted `vfs/` tree.
119    #[serde(default)]
120    pub derived: BTreeMap<String, String>,
121}
122
123impl BuildMetadata {
124    /// Load from disk. Returns None if the file doesn't exist or can't be parsed.
125    pub fn load(path: &Path) -> Option<Self> {
126        let contents = std::fs::read_to_string(path).ok()?;
127        toml::from_str(&contents).ok()
128    }
129
130    /// Save to disk.
131    pub fn save(&self, path: &Path) -> Result<(), rootcause::Report> {
132        use rootcause::prelude::*;
133        let contents = toml::to_string_pretty(self).attach_with(|| "Failed to serialize metadata.toml")?;
134        std::fs::write(path, &contents).attach_with(|| format!("Failed to write {}", path.display()))?;
135        Ok(())
136    }
137
138    /// Whether this metadata has CAS file hashes (new format).
139    pub fn has_file_hashes(&self) -> bool {
140        !self.files.is_empty()
141    }
142
143    /// Collect all unique CAS hashes referenced by this build, across both the
144    /// extracted `vfs/` tree and the derived artifacts.
145    pub fn referenced_hashes(&self) -> std::collections::HashSet<String> {
146        self.files.values().chain(self.derived.values()).cloned().collect()
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn builds_index_round_trip() {
156        let dir = tempfile::tempdir().unwrap();
157        let path = dir.path().join("builds.toml");
158
159        let mut index = BuildsIndex::default();
160        index.upsert(BuildEntry {
161            version: "15.1.0".into(),
162            build: 11965230,
163            dir: "15.1.0_11965230".into(),
164            dumped_at: "2025-06-15T10:00:00Z".into(),
165        });
166        index.upsert(BuildEntry {
167            version: "15.2.0".into(),
168            build: 12100000,
169            dir: "15.2.0_12100000".into(),
170            dumped_at: "2025-07-01T14:00:00Z".into(),
171        });
172
173        index.save(&path).unwrap();
174        let loaded = BuildsIndex::load(&path);
175        assert_eq!(loaded.builds.len(), 2);
176        assert_eq!(loaded.builds[0].build, 11965230);
177    }
178
179    #[test]
180    fn resolve_exact_match() {
181        let mut index = BuildsIndex::default();
182        index.upsert(BuildEntry {
183            version: "15.2.0".into(),
184            build: 12100000,
185            dir: "15.2.0_12100000".into(),
186            dumped_at: String::new(),
187        });
188
189        let (entry, exact) = index.resolve_build(12100000, None).unwrap();
190        assert!(exact);
191        assert_eq!(entry.build, 12100000);
192    }
193
194    #[test]
195    fn resolve_version_fallback() {
196        let mut index = BuildsIndex::default();
197        index.upsert(BuildEntry {
198            version: "15.2.0".into(),
199            build: 12100000,
200            dir: "15.2.0_12100000".into(),
201            dumped_at: String::new(),
202        });
203
204        // Different build but same version (e.g. CN server)
205        let (entry, exact) = index.resolve_build(12100500, Some("15.2.0")).unwrap();
206        assert!(!exact);
207        assert_eq!(entry.build, 12100000);
208    }
209
210    #[test]
211    fn resolve_no_match() {
212        let index = BuildsIndex::default();
213        assert!(index.resolve_build(99999, Some("99.0.0")).is_none());
214    }
215
216    #[test]
217    fn metadata_round_trip() {
218        let dir = tempfile::tempdir().unwrap();
219        let path = dir.path().join("metadata.toml");
220
221        let mut meta = BuildMetadata {
222            version: "15.2.0".into(),
223            build: 12100000,
224            files: BTreeMap::new(),
225            derived: BTreeMap::new(),
226        };
227        meta.files.insert("gui/test.png".into(), "abcdef1234567890abcd".into());
228
229        meta.save(&path).unwrap();
230        let loaded = BuildMetadata::load(&path).unwrap();
231        assert_eq!(loaded.files.len(), 1);
232        assert!(loaded.has_file_hashes());
233    }
234
235    #[test]
236    fn old_format_metadata_loads() {
237        let dir = tempfile::tempdir().unwrap();
238        let path = dir.path().join("metadata.toml");
239        std::fs::write(&path, "version = \"15.1.0\"\nbuild = 11965230\n").unwrap();
240
241        let loaded = BuildMetadata::load(&path).unwrap();
242        assert_eq!(loaded.version, "15.1.0");
243        assert!(!loaded.has_file_hashes());
244    }
245}