use crate::error::Result;
use crate::paths;
use crate::version_manager::platform::{DownloadSource, Platform};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MasterRecord {
pub etag: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_modified: Option<String>,
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeadInfo {
pub etag: String,
pub last_modified: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Sidecar {
#[serde(default)]
builds: BTreeMap<String, MasterRecord>,
}
fn sidecar_path() -> Result<std::path::PathBuf> {
Ok(paths::versions_dir()?.join(".master-builds.json"))
}
fn load_sidecar() -> Sidecar {
let Ok(path) = sidecar_path() else {
return Sidecar::default();
};
let Ok(bytes) = std::fs::read(&path) else {
return Sidecar::default();
};
serde_json::from_slice(&bytes).unwrap_or_default()
}
fn load_record(platform: &Platform) -> Option<MasterRecord> {
load_sidecar().builds.remove(platform.builds_path())
}
fn clear_version_from(sidecar: &mut Sidecar, platform_key: &str, version: &str) -> bool {
if sidecar
.builds
.get(platform_key)
.is_some_and(|r| r.version == version)
{
sidecar.builds.remove(platform_key);
true
} else {
false
}
}
pub fn clear_record_for_version(platform: &Platform, version: &str) -> Result<()> {
let mut sidecar = load_sidecar();
if clear_version_from(&mut sidecar, platform.builds_path(), version) {
let json = serde_json::to_vec_pretty(&sidecar)?;
std::fs::write(sidecar_path()?, json)?;
}
Ok(())
}
pub fn record(platform: &Platform, head: &HeadInfo, version: &str) -> Result<()> {
let mut sidecar = load_sidecar();
sidecar.builds.insert(
platform.builds_path().to_string(),
MasterRecord {
etag: head.etag.clone(),
last_modified: head.last_modified.clone(),
version: version.to_string(),
},
);
let path = sidecar_path()?;
let json = serde_json::to_vec_pretty(&sidecar)?;
std::fs::write(&path, json)?;
Ok(())
}
pub async fn head_info(platform: &Platform) -> Option<HeadInfo> {
let url = DownloadSource::Builds {
version_path: "master".to_string(),
}
.url(platform);
let client = reqwest::Client::builder()
.user_agent(crate::user_agent::user_agent())
.build()
.ok()?;
let resp = client.head(&url).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let header = |name: reqwest::header::HeaderName| {
resp.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
};
let etag = header(reqwest::header::ETAG)?;
let last_modified = header(reqwest::header::LAST_MODIFIED);
Some(HeadInfo {
etag,
last_modified,
})
}
fn should_reuse(
record: Option<&MasterRecord>,
remote_etag: Option<&str>,
binary_exists: bool,
) -> bool {
match (record, remote_etag) {
(Some(rec), Some(etag)) => rec.etag == etag && binary_exists,
_ => false,
}
}
pub fn reuse_if_unchanged(platform: &Platform, head: Option<&HeadInfo>) -> Option<String> {
let record = load_record(platform)?;
let binary_exists = paths::binary_path(&record.version)
.map(|p| p.exists())
.unwrap_or(false);
if should_reuse(Some(&record), head.map(|h| h.etag.as_str()), binary_exists) {
Some(record.version)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(etag: &str, version: &str) -> MasterRecord {
MasterRecord {
etag: etag.to_string(),
last_modified: None,
version: version.to_string(),
}
}
#[test]
fn reuse_when_etag_matches_and_binary_present() {
assert!(should_reuse(
Some(&rec("\"abc-1\"", "26.5.1.1")),
Some("\"abc-1\""),
true
));
}
#[test]
fn no_reuse_when_etag_differs() {
assert!(!should_reuse(
Some(&rec("\"abc-1\"", "26.5.1.1")),
Some("\"def-2\""),
true
));
}
#[test]
fn no_reuse_when_binary_missing() {
assert!(!should_reuse(
Some(&rec("\"abc-1\"", "26.5.1.1")),
Some("\"abc-1\""),
false
));
}
#[test]
fn no_reuse_when_no_record() {
assert!(!should_reuse(None, Some("\"abc-1\""), true));
}
#[test]
fn no_reuse_when_head_failed() {
assert!(!should_reuse(
Some(&rec("\"abc-1\"", "26.5.1.1")),
None,
true
));
}
#[test]
fn sidecar_round_trips_and_preserves_other_platforms() {
let mut sidecar = Sidecar::default();
sidecar
.builds
.insert("amd64".to_string(), rec("\"x-1\"", "26.5.1.1"));
sidecar
.builds
.insert("macos-aarch64".to_string(), rec("\"y-2\"", "26.5.1.1"));
let json = serde_json::to_vec_pretty(&sidecar).unwrap();
let back: Sidecar = serde_json::from_slice(&json).unwrap();
assert_eq!(back.builds.get("amd64").unwrap().etag, "\"x-1\"");
assert_eq!(back.builds.get("macos-aarch64").unwrap().etag, "\"y-2\"");
}
#[test]
fn corrupt_sidecar_deserializes_to_default() {
let back: Sidecar = serde_json::from_slice(b"not json").unwrap_or_default();
assert!(back.builds.is_empty());
}
#[test]
fn clear_version_removes_matching_record() {
let mut sidecar = Sidecar::default();
sidecar
.builds
.insert("macos-aarch64".to_string(), rec("\"x-1\"", "26.5.1.1"));
assert!(clear_version_from(
&mut sidecar,
"macos-aarch64",
"26.5.1.1"
));
assert!(!sidecar.builds.contains_key("macos-aarch64"));
}
#[test]
fn clear_version_keeps_record_for_other_version() {
let mut sidecar = Sidecar::default();
sidecar
.builds
.insert("macos-aarch64".to_string(), rec("\"x-1\"", "26.5.1.1"));
assert!(!clear_version_from(
&mut sidecar,
"macos-aarch64",
"25.12.9.61"
));
assert!(sidecar.builds.contains_key("macos-aarch64"));
}
#[test]
fn clear_version_keeps_other_platforms() {
let mut sidecar = Sidecar::default();
sidecar
.builds
.insert("amd64".to_string(), rec("\"x-1\"", "26.5.1.1"));
sidecar
.builds
.insert("macos-aarch64".to_string(), rec("\"y-2\"", "26.5.1.1"));
assert!(clear_version_from(
&mut sidecar,
"macos-aarch64",
"26.5.1.1"
));
assert!(sidecar.builds.contains_key("amd64"));
}
#[test]
fn clear_version_no_record_is_noop() {
let mut sidecar = Sidecar::default();
assert!(!clear_version_from(
&mut sidecar,
"macos-aarch64",
"26.5.1.1"
));
}
}