pub mod installation;
use std::path::Path;
use serde::{Deserialize, Serialize};
pub use self::installation::{
Installation, Output, PublicationId, PublicationRef, RealizedMethod, Receipt,
};
use crate::{identity::InstallationId, manifest::version};
pub const STATE_VERSION: semver::Version = semver::Version::new(2, 0, 0);
#[derive(Debug, thiserror::Error)]
pub enum StateError {
#[error("failed to read local state from '{path}': {source}")]
Io {
path: std::path::PathBuf,
#[source]
source: std::io::Error,
},
#[error("invalid local state in '{path}': {reason}")]
Invalid { path: std::path::PathBuf, reason: String },
#[error(
"'{path}' is a {found} document, but a {expected} document was expected; midenup keeps \
these separate and will not read one as the other"
)]
WrongDocumentType {
path: std::path::PathBuf,
expected: &'static str,
found: &'static str,
},
#[error("local state in '{path}' declares version {found}, which requires a newer midenup")]
RequiresNewer {
path: std::path::PathBuf,
found: semver::Version,
},
#[error("failed to write local state: {0}")]
Write(#[from] crate::utils::atomic::WriteError),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LocalState {
pub state_version: semver::Version,
#[serde(default)]
pub installations: Vec<Installation>,
}
impl Default for LocalState {
fn default() -> Self {
Self {
state_version: STATE_VERSION,
installations: Vec::new(),
}
}
}
impl LocalState {
pub fn load(path: &Path) -> Result<Self, StateError> {
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self::default());
},
Err(source) => return Err(StateError::Io { path: path.to_path_buf(), source }),
};
if contents.trim().is_empty() {
return Ok(Self::default());
}
Self::parse_str(&contents, path)
}
pub fn parse_str(contents: &str, path: &Path) -> Result<Self, StateError> {
if version::read_version_header(contents, "manifest_version").is_ok()
&& version::read_version_header(contents, "state_version").is_err()
{
return Err(StateError::WrongDocumentType {
path: path.to_path_buf(),
expected: "local state",
found: "channel manifest",
});
}
let header = version::read_version_header(contents, "state_version").map_err(|err| {
StateError::Invalid {
path: path.to_path_buf(),
reason: err.to_string(),
}
})?;
match version::classify(
&header.version,
if header.version.major == 1 {
1
} else {
STATE_VERSION.major
},
) {
version::Compatibility::Supported => {},
version::Compatibility::RequiresNewer { found } => {
return Err(StateError::RequiresNewer { path: path.to_path_buf(), found });
},
version::Compatibility::TooOld { found } => {
return Err(StateError::Invalid {
path: path.to_path_buf(),
reason: format!("unsupported state version {found}"),
});
},
}
let mut state: Self =
serde_json::from_str(contents).map_err(|err| StateError::Invalid {
path: path.to_path_buf(),
reason: err.to_string(),
})?;
let invalid = |reason| StateError::Invalid { path: path.to_path_buf(), reason };
let mut identities = std::collections::BTreeSet::new();
for installation in &state.installations {
if header.version.major == 1 && installation.custom.is_some() {
return Err(invalid("state version 1 cannot contain custom installations".into()));
}
if installation.custom.as_ref().is_some_and(|custom| {
matches!(custom.channel, crate::channel::UserChannel::Custom(_))
}) {
return Err(invalid(
"a custom toolchain source must be an upstream version or network".into(),
));
}
if !identities.insert(installation.id()) {
return Err(invalid(format!(
"duplicate installation identity '{}'",
installation.id()
)));
}
}
state.state_version = STATE_VERSION;
Ok(state)
}
pub fn save(&self, path: &Path) -> Result<(), StateError> {
crate::trace!("writing {}", path.display());
let mut current = self.clone();
current.state_version = STATE_VERSION;
crate::utils::atomic::write_validated(path, ¤t, |written| {
LocalState::parse_str(written, path)
.map(|_| ())
.map_err(|err| format!("the result would not parse as local state: {err}"))
})?;
Ok(())
}
pub fn get(&self, channel: &semver::Version) -> Option<&Installation> {
self.get_by_id(&InstallationId::Version(channel.clone()))
}
pub fn get_mut(&mut self, channel: &semver::Version) -> Option<&mut Installation> {
self.get_mut_by_id(&InstallationId::Version(channel.clone()))
}
pub fn get_by_id(&self, id: &InstallationId) -> Option<&Installation> {
self.installations.iter().find(|installation| &installation.id() == id)
}
pub fn get_mut_by_id(&mut self, id: &InstallationId) -> Option<&mut Installation> {
self.installations.iter_mut().find(|installation| &installation.id() == id)
}
pub fn upsert(&mut self, installation: Installation) {
self.remove_by_id(&installation.id());
self.installations.push(installation);
self.installations.sort_by_key(Installation::id);
}
pub fn remove(&mut self, channel: &semver::Version) {
self.remove_by_id(&InstallationId::Version(channel.clone()));
}
pub fn remove_by_id(&mut self, id: &InstallationId) {
self.installations.retain(|installation| &installation.id() != id);
}
pub fn channels(&self) -> impl Iterator<Item = &semver::Version> {
self.installations
.iter()
.filter(|installation| installation.custom.is_none())
.map(|installation| &installation.channel)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{plan::PlanKey, profile::Profile, resolve::Intent};
fn sample() -> LocalState {
LocalState {
state_version: STATE_VERSION,
installations: vec![Installation {
channel: semver::Version::new(0, 15, 0),
custom: None,
intent: Intent::new(&[Profile::Minimal], &["client"]),
components: vec![],
publication: PublicationRef::Managed {
id: PublicationId::generate(),
plan_key: serde_json::from_str::<PlanKey>(&format!(
"\"pk1:{}\"",
"a".repeat(64)
))
.unwrap(),
target: "aarch64-apple-darwin".to_string(),
},
installed_at: 1735689600,
midenup_version: None,
patches: Default::default(),
}],
}
}
#[test]
fn duplicate_installation_identities_are_rejected() {
let mut state = sample();
state.installations.push(state.installations[0].clone());
let json = serde_json::to_string(&state).unwrap();
assert!(LocalState::parse_str(&json, Path::new("state.json")).is_err());
}
#[test]
fn invalid_custom_names_and_sources_are_rejected() {
for (name, channel) in [("../escape", "mainnet"), ("dev", "custom:other")] {
let mut value = serde_json::to_value(sample()).unwrap();
value["installations"][0]["custom"] =
serde_json::json!({"name": name, "channel": channel});
assert!(LocalState::parse_str(&value.to_string(), Path::new("state.json")).is_err());
}
}
#[test]
fn named_installations_coexist_with_canonical_and_replace_only_their_identity() {
use crate::identity::CustomToolchain;
let mut state = sample();
let canonical = state.installations[0].clone();
for name in ["first", "second"] {
let mut named = canonical.clone();
named.custom = Some(CustomToolchain {
name: name.parse().unwrap(),
channel: "mainnet".parse().unwrap(),
});
state.upsert(named);
}
assert_eq!(state.installations.len(), 3);
assert_eq!(state.channels().count(), 1);
let first = InstallationId::Custom("first".parse().unwrap());
let mut updated = state.get_by_id(&first).unwrap().clone();
updated.channel = semver::Version::new(0, 16, 0);
state.upsert(updated);
assert_eq!(state.installations.len(), 3);
assert_eq!(state.get_by_id(&first).unwrap().channel, semver::Version::new(0, 16, 0));
state.remove(&canonical.channel);
assert_eq!(state.installations.len(), 2);
assert!(state.get(&canonical.channel).is_none());
state.remove_by_id(&first);
assert_eq!(state.installations.len(), 1);
let json = serde_json::to_string(&state).unwrap();
assert_eq!(LocalState::parse_str(&json, Path::new("state.json")).unwrap(), state);
}
#[test]
fn legacy_state_load_is_read_only_and_next_save_upgrades_version() {
let dir = tempdir::TempDir::new("legacy-state").unwrap();
let path = dir.path().join("state.json");
let mut legacy = serde_json::to_value(sample()).unwrap();
legacy["state_version"] = "1.0.0".into();
let original = legacy.to_string();
std::fs::write(&path, &original).unwrap();
let state = LocalState::load(&path).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
assert!(state.installations[0].custom.is_none());
state.save(&path).unwrap();
let written: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(written["state_version"], "2.0.0");
}
#[test]
fn state_round_trips() {
let dir = tempdir::TempDir::new("state-roundtrip").unwrap();
let path = dir.path().join("state.json");
let state = sample();
state.save(&path).unwrap();
assert_eq!(LocalState::load(&path).unwrap(), state);
}
#[test]
fn a_manifest_is_rejected_where_state_is_expected() {
let dir = tempdir::TempDir::new("state-wrongdoc").unwrap();
let path = dir.path().join("state.json");
std::fs::write(&path, r#"{"manifest_version":"2.0.0","date":1,"channels":[]}"#).unwrap();
let err = LocalState::load(&path).expect_err("must refuse a manifest");
assert!(
matches!(err, StateError::WrongDocumentType { found: "channel manifest", .. }),
"expected WrongDocumentType, got: {err}"
);
}
#[test]
fn a_missing_state_file_is_an_empty_state_not_an_error() {
let dir = tempdir::TempDir::new("state-missing").unwrap();
let state = LocalState::load(&dir.path().join("state.json")).unwrap();
assert!(state.installations.is_empty());
assert_eq!(state.state_version, STATE_VERSION);
}
#[test]
fn an_empty_state_file_is_an_empty_state() {
let dir = tempdir::TempDir::new("state-empty").unwrap();
let path = dir.path().join("state.json");
std::fs::write(&path, "").unwrap();
assert!(LocalState::load(&path).unwrap().installations.is_empty());
}
#[test]
fn a_newer_major_state_version_requires_a_newer_midenup() {
let dir = tempdir::TempDir::new("state-newer").unwrap();
let path = dir.path().join("state.json");
std::fs::write(&path, r#"{"state_version":"3.0.0","installations":[]}"#).unwrap();
assert!(matches!(LocalState::load(&path), Err(StateError::RequiresNewer { .. })));
}
#[test]
fn upsert_replaces_and_keeps_channels_ordered() {
let mut state = LocalState::default();
for version in [(0, 16, 0), (0, 14, 0), (0, 15, 0)] {
let mut installation = sample().installations.remove(0);
installation.channel = semver::Version::new(version.0, version.1, version.2);
state.upsert(installation);
}
assert_eq!(state.installations.len(), 3);
assert!(state.channels().cloned().collect::<Vec<_>>().is_sorted());
let mut replacement = sample().installations.remove(0);
replacement.channel = semver::Version::new(0, 15, 0);
replacement.installed_at = 42;
state.upsert(replacement);
assert_eq!(state.installations.len(), 3, "upsert must replace, not append");
assert_eq!(state.get(&semver::Version::new(0, 15, 0)).unwrap().installed_at, 42);
}
#[test]
fn remove_drops_only_the_named_channel() {
let mut state = sample();
state.remove(&semver::Version::new(0, 14, 0));
assert_eq!(state.installations.len(), 1, "removing an absent channel is a no-op");
state.remove(&semver::Version::new(0, 15, 0));
assert!(state.installations.is_empty());
}
#[test]
fn an_unprefixed_plan_key_is_rejected() {
let dir = tempdir::TempDir::new("state-badkey").unwrap();
let path = dir.path().join("state.json");
std::fs::write(
&path,
r#"{"state_version":"1.0.0","installations":[{"channel":"0.15.0",
"intent":{},"components":[],
"publication":{"kind":"managed","id":"abc","plan_key":"deadbeef","target":"t"},
"installed_at":1}]}"#,
)
.unwrap();
assert!(matches!(LocalState::load(&path), Err(StateError::Invalid { .. })));
}
#[test]
fn state_given_to_the_manifest_parser_is_rejected_by_name() {
let err = crate::manifest::VersionedManifest::parse_str(
r#"{"state_version":"1.0.0","installations":[]}"#,
)
.expect_err("must refuse local state");
assert!(
matches!(
err,
crate::manifest::ManifestError::WrongDocumentType { found: "local state", .. }
),
"expected WrongDocumentType, got: {err}"
);
}
#[test]
fn publication_ids_are_unique() {
let ids: std::collections::HashSet<_> =
(0..1000).map(|_| PublicationId::generate()).collect();
assert_eq!(ids.len(), 1000, "generated ids must not collide");
}
#[test]
fn a_needs_reinstall_record_is_not_managed() {
let mut state = sample();
state.installations[0].publication = PublicationRef::NeedsReinstall;
assert!(!state.installations[0].is_managed());
let dir = tempdir::TempDir::new("state-needsreinstall").unwrap();
let path = dir.path().join("state.json");
state.save(&path).unwrap();
assert_eq!(LocalState::load(&path).unwrap(), state, "must round-trip");
}
}