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}
117
118impl BuildMetadata {
119    /// Load from disk. Returns None if the file doesn't exist or can't be parsed.
120    pub fn load(path: &Path) -> Option<Self> {
121        let contents = std::fs::read_to_string(path).ok()?;
122        toml::from_str(&contents).ok()
123    }
124
125    /// Save to disk.
126    pub fn save(&self, path: &Path) -> Result<(), rootcause::Report> {
127        use rootcause::prelude::*;
128        let contents = toml::to_string_pretty(self).attach_with(|| "Failed to serialize metadata.toml")?;
129        std::fs::write(path, &contents).attach_with(|| format!("Failed to write {}", path.display()))?;
130        Ok(())
131    }
132
133    /// Whether this metadata has CAS file hashes (new format).
134    pub fn has_file_hashes(&self) -> bool {
135        !self.files.is_empty()
136    }
137
138    /// Collect all unique hashes referenced by this build.
139    pub fn referenced_hashes(&self) -> std::collections::HashSet<String> {
140        self.files.values().cloned().collect()
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn builds_index_round_trip() {
150        let dir = tempfile::tempdir().unwrap();
151        let path = dir.path().join("builds.toml");
152
153        let mut index = BuildsIndex::default();
154        index.upsert(BuildEntry {
155            version: "15.1.0".into(),
156            build: 11965230,
157            dir: "15.1.0_11965230".into(),
158            dumped_at: "2025-06-15T10:00:00Z".into(),
159        });
160        index.upsert(BuildEntry {
161            version: "15.2.0".into(),
162            build: 12100000,
163            dir: "15.2.0_12100000".into(),
164            dumped_at: "2025-07-01T14:00:00Z".into(),
165        });
166
167        index.save(&path).unwrap();
168        let loaded = BuildsIndex::load(&path);
169        assert_eq!(loaded.builds.len(), 2);
170        assert_eq!(loaded.builds[0].build, 11965230);
171    }
172
173    #[test]
174    fn resolve_exact_match() {
175        let mut index = BuildsIndex::default();
176        index.upsert(BuildEntry {
177            version: "15.2.0".into(),
178            build: 12100000,
179            dir: "15.2.0_12100000".into(),
180            dumped_at: String::new(),
181        });
182
183        let (entry, exact) = index.resolve_build(12100000, None).unwrap();
184        assert!(exact);
185        assert_eq!(entry.build, 12100000);
186    }
187
188    #[test]
189    fn resolve_version_fallback() {
190        let mut index = BuildsIndex::default();
191        index.upsert(BuildEntry {
192            version: "15.2.0".into(),
193            build: 12100000,
194            dir: "15.2.0_12100000".into(),
195            dumped_at: String::new(),
196        });
197
198        // Different build but same version (e.g. CN server)
199        let (entry, exact) = index.resolve_build(12100500, Some("15.2.0")).unwrap();
200        assert!(!exact);
201        assert_eq!(entry.build, 12100000);
202    }
203
204    #[test]
205    fn resolve_no_match() {
206        let index = BuildsIndex::default();
207        assert!(index.resolve_build(99999, Some("99.0.0")).is_none());
208    }
209
210    #[test]
211    fn metadata_round_trip() {
212        let dir = tempfile::tempdir().unwrap();
213        let path = dir.path().join("metadata.toml");
214
215        let mut meta = BuildMetadata { version: "15.2.0".into(), build: 12100000, files: BTreeMap::new() };
216        meta.files.insert("gui/test.png".into(), "abcdef1234567890abcd".into());
217
218        meta.save(&path).unwrap();
219        let loaded = BuildMetadata::load(&path).unwrap();
220        assert_eq!(loaded.files.len(), 1);
221        assert!(loaded.has_file_hashes());
222    }
223
224    #[test]
225    fn old_format_metadata_loads() {
226        let dir = tempfile::tempdir().unwrap();
227        let path = dir.path().join("metadata.toml");
228        std::fs::write(&path, "version = \"15.1.0\"\nbuild = 11965230\n").unwrap();
229
230        let loaded = BuildMetadata::load(&path).unwrap();
231        assert_eq!(loaded.version, "15.1.0");
232        assert!(!loaded.has_file_hashes());
233    }
234}