use std::collections::BTreeMap;
use std::path::Path;
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct BuildsIndex {
#[serde(default)]
pub builds: Vec<BuildEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuildEntry {
pub version: String,
pub build: u32,
pub dir: String,
pub dumped_at: String,
}
impl BuildsIndex {
pub fn load(path: &Path) -> Self {
std::fs::read_to_string(path).ok().and_then(|s| toml::from_str(&s).ok()).unwrap_or_default()
}
pub fn save(&self, path: &Path) -> Result<(), rootcause::Report> {
use rootcause::prelude::*;
let contents = toml::to_string_pretty(self).attach_with(|| "Failed to serialize builds.toml")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.attach_with(|| format!("Failed to create directory {}", parent.display()))?;
}
let tmp = path.with_extension("toml.tmp");
std::fs::write(&tmp, &contents).attach_with(|| format!("Failed to write {}", tmp.display()))?;
std::fs::rename(&tmp, path)
.attach_with(|| format!("Failed to rename {} to {}", tmp.display(), path.display()))?;
Ok(())
}
pub fn upsert(&mut self, entry: BuildEntry) {
if let Some(existing) = self.builds.iter_mut().find(|e| e.build == entry.build) {
*existing = entry;
} else {
self.builds.push(entry);
}
self.builds.sort_by_key(|e| e.build);
}
pub fn remove_build(&mut self, build: u32) -> Option<BuildEntry> {
let idx = self.builds.iter().position(|e| e.build == build)?;
Some(self.builds.remove(idx))
}
pub fn find_by_build(&self, build: u32) -> Option<&BuildEntry> {
self.builds.iter().find(|e| e.build == build)
}
pub fn find_by_version(&self, version_query: &str) -> Vec<&BuildEntry> {
self.builds.iter().filter(|e| crate::manifest::version_matches(&e.version, version_query)).collect()
}
pub fn resolve_build(&self, target_build: u32, target_version: Option<&str>) -> Option<(&BuildEntry, bool)> {
if let Some(entry) = self.find_by_build(target_build) {
return Some((entry, true));
}
if let Some(version) = target_version {
let candidates = self.find_by_version(version);
if !candidates.is_empty() {
let closest =
candidates.iter().min_by_key(|e| (e.build as i64 - target_build as i64).unsigned_abs()).unwrap();
return Some((closest, false));
}
}
None
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct BuildMetadata {
pub version: String,
pub build: u32,
#[serde(default)]
pub files: BTreeMap<String, String>,
}
impl BuildMetadata {
pub fn load(path: &Path) -> Option<Self> {
let contents = std::fs::read_to_string(path).ok()?;
toml::from_str(&contents).ok()
}
pub fn save(&self, path: &Path) -> Result<(), rootcause::Report> {
use rootcause::prelude::*;
let contents = toml::to_string_pretty(self).attach_with(|| "Failed to serialize metadata.toml")?;
std::fs::write(path, &contents).attach_with(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
pub fn has_file_hashes(&self) -> bool {
!self.files.is_empty()
}
pub fn referenced_hashes(&self) -> std::collections::HashSet<String> {
self.files.values().cloned().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_index_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("builds.toml");
let mut index = BuildsIndex::default();
index.upsert(BuildEntry {
version: "15.1.0".into(),
build: 11965230,
dir: "15.1.0_11965230".into(),
dumped_at: "2025-06-15T10:00:00Z".into(),
});
index.upsert(BuildEntry {
version: "15.2.0".into(),
build: 12100000,
dir: "15.2.0_12100000".into(),
dumped_at: "2025-07-01T14:00:00Z".into(),
});
index.save(&path).unwrap();
let loaded = BuildsIndex::load(&path);
assert_eq!(loaded.builds.len(), 2);
assert_eq!(loaded.builds[0].build, 11965230);
}
#[test]
fn resolve_exact_match() {
let mut index = BuildsIndex::default();
index.upsert(BuildEntry {
version: "15.2.0".into(),
build: 12100000,
dir: "15.2.0_12100000".into(),
dumped_at: String::new(),
});
let (entry, exact) = index.resolve_build(12100000, None).unwrap();
assert!(exact);
assert_eq!(entry.build, 12100000);
}
#[test]
fn resolve_version_fallback() {
let mut index = BuildsIndex::default();
index.upsert(BuildEntry {
version: "15.2.0".into(),
build: 12100000,
dir: "15.2.0_12100000".into(),
dumped_at: String::new(),
});
let (entry, exact) = index.resolve_build(12100500, Some("15.2.0")).unwrap();
assert!(!exact);
assert_eq!(entry.build, 12100000);
}
#[test]
fn resolve_no_match() {
let index = BuildsIndex::default();
assert!(index.resolve_build(99999, Some("99.0.0")).is_none());
}
#[test]
fn metadata_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("metadata.toml");
let mut meta = BuildMetadata { version: "15.2.0".into(), build: 12100000, files: BTreeMap::new() };
meta.files.insert("gui/test.png".into(), "abcdef1234567890abcd".into());
meta.save(&path).unwrap();
let loaded = BuildMetadata::load(&path).unwrap();
assert_eq!(loaded.files.len(), 1);
assert!(loaded.has_file_hashes());
}
#[test]
fn old_format_metadata_loads() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("metadata.toml");
std::fs::write(&path, "version = \"15.1.0\"\nbuild = 11965230\n").unwrap();
let loaded = BuildMetadata::load(&path).unwrap();
assert_eq!(loaded.version, "15.1.0");
assert!(!loaded.has_file_hashes());
}
}