1use std::collections::BTreeMap;
8use std::path::Path;
9
10use serde::Deserialize;
11use serde::Serialize;
12
13#[derive(Debug, Default, Serialize, Deserialize)]
17pub struct BuildsIndex {
18 #[serde(default)]
19 pub builds: Vec<BuildEntry>,
20}
21
22#[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 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 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 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 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 pub fn find_by_build(&self, build: u32) -> Option<&BuildEntry> {
70 self.builds.iter().find(|e| e.build == build)
71 }
72
73 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 pub fn resolve_build(&self, target_build: u32, target_version: Option<&str>) -> Option<(&BuildEntry, bool)> {
87 if let Some(entry) = self.find_by_build(target_build) {
89 return Some((entry, true));
90 }
91
92 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#[derive(Debug, Default, Serialize, Deserialize)]
110pub struct BuildMetadata {
111 pub version: String,
112 pub build: u32,
113 #[serde(default)]
115 pub files: BTreeMap<String, String>,
116}
117
118impl BuildMetadata {
119 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 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 pub fn has_file_hashes(&self) -> bool {
135 !self.files.is_empty()
136 }
137
138 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 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}