pub mod installation;
use std::path::Path;
use serde::{Deserialize, Serialize};
pub use self::installation::{
Installation, Output, PublicationId, PublicationRef, RealizedMethod, Receipt,
};
use crate::manifest::version;
pub const STATE_VERSION: semver::Version = semver::Version::new(1, 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, 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}"),
});
},
}
serde_json::from_str(contents).map_err(|err| StateError::Invalid {
path: path.to_path_buf(),
reason: err.to_string(),
})
}
pub fn save(&self, path: &Path) -> Result<(), StateError> {
crate::trace!("writing {}", path.display());
crate::utils::atomic::write_validated(path, self, |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.installations.iter().find(|i| &i.channel == channel)
}
pub fn get_mut(&mut self, channel: &semver::Version) -> Option<&mut Installation> {
self.installations.iter_mut().find(|i| &i.channel == channel)
}
pub fn upsert(&mut self, installation: Installation) {
self.remove(&installation.channel);
self.installations.push(installation);
self.installations.sort_by(|a, b| a.channel.cmp(&b.channel));
}
pub fn remove(&mut self, channel: &semver::Version) {
self.installations.retain(|i| &i.channel != channel);
}
pub fn channels(&self) -> impl Iterator<Item = &semver::Version> {
self.installations.iter().map(|i| &i.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),
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 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":"2.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");
}
}