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 #[serde(default)]
120 pub derived: BTreeMap<String, String>,
121}
122
123impl BuildMetadata {
124 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 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 pub fn has_file_hashes(&self) -> bool {
140 !self.files.is_empty()
141 }
142
143 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 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}