use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::{
paths,
publish::PublishError,
state::{Installation, LocalState, PublicationId, PublicationRef},
utils,
};
const TOMBSTONE: &str = ".uninstalled";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum OperationKind {
Install,
Uninstall,
ChannelMigrate,
}
impl std::fmt::Display for OperationKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Install => "install",
Self::Uninstall => "uninstall",
Self::ChannelMigrate => "channel migration",
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JournalEntry {
pub id: String,
pub kind: OperationKind,
pub channel: semver::Version,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub old_publication: Option<PublicationId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub new_publication: Option<PublicationId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_installation: Option<Installation>,
}
impl JournalEntry {
pub fn install(
channel: semver::Version,
old_publication: Option<PublicationId>,
new_publication: PublicationId,
target_installation: Installation,
) -> Self {
Self {
id: utils::opaque_id(),
kind: OperationKind::Install,
channel,
old_publication,
new_publication: Some(new_publication),
target_installation: Some(target_installation),
}
}
pub fn uninstall(channel: semver::Version, publication: Option<PublicationId>) -> Self {
Self {
id: utils::opaque_id(),
kind: OperationKind::Uninstall,
channel,
old_publication: publication,
new_publication: None,
target_installation: None,
}
}
}
pub fn entry_path(home: &Path, id: &str) -> PathBuf {
paths::journal_dir(home).join(id).with_extension("json")
}
pub fn prepare(home: &Path, entry: &JournalEntry) -> Result<(), PublishError> {
let dir = paths::journal_dir(home);
std::fs::create_dir_all(&dir)
.map_err(|source| PublishError::Journal { path: dir.clone(), source })?;
if let Some(existing) = read(home)?
&& existing.id != entry.id
{
return Err(PublishError::OperationInProgress {
operation: existing.kind,
channel: existing.channel,
});
}
let path = entry_path(home, &entry.id);
crate::utils::atomic::write_validated(&path, entry, |written| {
serde_json::from_str::<JournalEntry>(written)
.map(|_| ())
.map_err(|err| format!("the result would not parse as a journal entry: {err}"))
})?;
Ok(())
}
pub fn commit_symlink(home: &Path, entry: &JournalEntry) -> Result<(), PublishError> {
let link = paths::toolchain_link(home, &entry.channel);
let target = match (&entry.kind, &entry.new_publication) {
(OperationKind::Uninstall, _) | (_, None) => PathBuf::from(TOMBSTONE),
(_, Some(id)) => {
PathBuf::from("..").join("publications").join(format!("{}-{id}", entry.channel))
},
};
crate::trace!("committing {} to {}", link.display(), target.display());
utils::fs::replace_symlink(&link, &target).map_err(|err| PublishError::Commit {
path: link,
source: std::io::Error::other(err.to_string()),
})
}
pub fn record(
home: &Path,
entry: &JournalEntry,
state: &mut LocalState,
) -> Result<(), PublishError> {
match entry.kind {
OperationKind::Install | OperationKind::ChannelMigrate => {
if let Some(installation) = entry.target_installation.clone() {
state.upsert(installation);
}
},
OperationKind::Uninstall => state.remove(&entry.channel),
}
state
.save(&paths::state_path(home))
.map_err(|err| PublishError::Record { reason: err.to_string() })
}
pub fn clean(home: &Path, entry: &JournalEntry) -> Result<(), PublishError> {
if let Some(old) = &entry.old_publication
&& matches!(entry.kind, OperationKind::Uninstall)
{
let publication = paths::publication_dir(home, &entry.channel, old);
crate::trace!("removing {}", publication.display());
let _ = std::fs::remove_dir_all(publication);
}
if matches!(entry.kind, OperationKind::Uninstall) {
let link = paths::toolchain_link(home, &entry.channel);
if is_tombstone(&link) {
let _ = std::fs::remove_file(&link);
}
}
let path = entry_path(home, &entry.id);
std::fs::remove_file(&path).map_err(|source| PublishError::Journal { path, source })
}
pub fn finish(
home: &Path,
entry: &JournalEntry,
state: &mut LocalState,
) -> Result<(), PublishError> {
record(home, entry, state)?;
clean(home, entry)
}
pub fn recover(home: &Path, state: &mut LocalState) -> Result<Option<OperationKind>, PublishError> {
let Some(entry) = read(home)? else {
return check_divergence(home, state).map(|_| None);
};
let link = paths::toolchain_link(home, &entry.channel);
let committed = match entry.kind {
OperationKind::Uninstall => is_tombstone(&link),
OperationKind::Install | OperationKind::ChannelMigrate => entry
.new_publication
.as_ref()
.is_some_and(|id| points_at(&link, &format!("{}-{id}", entry.channel))),
};
if committed {
crate::trace!(
"the interrupted {} of {} was committed; completing it",
entry.kind,
entry.channel
);
finish(home, &entry, state)?;
return Ok(Some(entry.kind));
}
crate::trace!(
"the interrupted {} of {} never committed; discarding it",
entry.kind,
entry.channel
);
if let Some(new) = &entry.new_publication {
let publication = paths::publication_dir(home, &entry.channel, new);
crate::trace!("removing {}", publication.display());
let _ = std::fs::remove_dir_all(publication);
}
let path = entry_path(home, &entry.id);
crate::trace!("removing {}", path.display());
std::fs::remove_file(&path).map_err(|source| PublishError::Journal { path, source })?;
Ok(Some(entry.kind))
}
pub fn read(home: &Path) -> Result<Option<JournalEntry>, PublishError> {
let dir = paths::journal_dir(home);
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => return Err(PublishError::Journal { path: dir, source }),
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "json") {
continue;
}
let contents = std::fs::read_to_string(&path)
.map_err(|source| PublishError::Journal { path: path.clone(), source })?;
let parsed = serde_json::from_str(&contents)
.map_err(|err| PublishError::InvalidJournal { path, reason: err.to_string() })?;
return Ok(Some(parsed));
}
Ok(None)
}
fn points_at(link: &Path, name: &str) -> bool {
std::fs::read_link(link)
.ok()
.and_then(|target| target.file_name().map(|n| n == std::ffi::OsStr::new(name)))
.unwrap_or(false)
}
fn is_tombstone(link: &Path) -> bool {
std::fs::read_link(link).is_ok_and(|target| target == Path::new(TOMBSTONE))
}
fn check_divergence(home: &Path, state: &LocalState) -> Result<(), PublishError> {
for installation in &state.installations {
let PublicationRef::Managed { id, .. } = &installation.publication else {
continue;
};
let dir = paths::publication_dir(home, &installation.channel, id);
if !dir.is_dir() {
return Err(PublishError::DivergentState {
channel: installation.channel.clone(),
detail: format!("its publication '{}' is missing", dir.display()),
remediation: format!("midenup install {}", installation.channel),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{plan::PlanKey, profile::Profile, resolve::Intent};
struct Env {
_temp: tempdir::TempDir,
home: PathBuf,
}
impl Env {
fn with_installed(channel: &str) -> (Self, PublicationId) {
let temp = tempdir::TempDir::new("journal").unwrap();
let home = temp.path().join("midenup");
let channel = semver::Version::parse(channel).unwrap();
let id = PublicationId::generate();
let publication = paths::publication_dir(&home, &channel, &id);
std::fs::create_dir_all(&publication).unwrap();
std::fs::create_dir_all(paths::toolchains_dir(&home)).unwrap();
utils::fs::symlink(
&paths::toolchain_link(&home, &channel),
&PathBuf::from("..").join("publications").join(publication.file_name().unwrap()),
)
.unwrap();
let mut state = LocalState::default();
state.upsert(installation(&channel, &id));
state.save(&paths::state_path(&home)).unwrap();
(Env { _temp: temp, home }, id)
}
fn state(&self) -> LocalState {
LocalState::load(&paths::state_path(&self.home)).unwrap()
}
fn journal_is_empty(&self) -> bool {
read(&self.home).unwrap().is_none()
}
}
fn plan_key() -> PlanKey {
serde_json::from_str(&format!("\"pk1:{}\"", "a".repeat(64))).unwrap()
}
fn installation(channel: &semver::Version, id: &PublicationId) -> Installation {
Installation {
channel: channel.clone(),
intent: Intent::new(&[Profile::Minimal], &[]),
components: vec![],
publication: PublicationRef::Managed {
id: id.clone(),
plan_key: plan_key(),
target: "aarch64-apple-darwin".to_string(),
},
installed_at: 1735689600,
}
}
fn v(version: &str) -> semver::Version {
semver::Version::parse(version).unwrap()
}
fn stage_publication(home: &Path, channel: &semver::Version, id: &PublicationId) {
std::fs::create_dir_all(paths::publication_dir(home, channel, id)).unwrap();
}
#[test]
fn before_the_symlink_commit_recovery_discards_and_keeps_the_old_state() {
let (env, _) = Env::with_installed("0.15.0");
let new = PublicationId::generate();
let entry =
JournalEntry::install(v("0.16.0"), None, new.clone(), installation(&v("0.16.0"), &new));
prepare(&env.home, &entry).unwrap();
stage_publication(&env.home, &v("0.16.0"), &new);
let mut state = env.state();
recover(&env.home, &mut state).unwrap();
assert!(state.get(&v("0.16.0")).is_none(), "uncommitted operation must be discarded");
assert!(state.get(&v("0.15.0")).is_some(), "prior installation must survive");
assert!(
!paths::publication_dir(&env.home, &v("0.16.0"), &new).exists(),
"the staged publication must be discarded"
);
assert!(env.journal_is_empty());
}
#[test]
fn after_the_symlink_commit_recovery_rolls_forward() {
let (env, _) = Env::with_installed("0.15.0");
let new = PublicationId::generate();
let entry =
JournalEntry::install(v("0.16.0"), None, new.clone(), installation(&v("0.16.0"), &new));
prepare(&env.home, &entry).unwrap();
stage_publication(&env.home, &v("0.16.0"), &new);
commit_symlink(&env.home, &entry).unwrap();
let mut state = env.state();
assert!(state.get(&v("0.16.0")).is_none(), "state must not know about it yet");
recover(&env.home, &mut state).unwrap();
assert!(state.get(&v("0.16.0")).is_some(), "committed operation must be completed");
assert_eq!(
env.state().get(&v("0.16.0")).map(|i| i.channel.clone()),
Some(v("0.16.0")),
"and must be persisted, not merely applied in memory"
);
assert!(env.journal_is_empty());
}
#[test]
fn a_replaced_publication_is_left_for_gc() {
let (env, old) = Env::with_installed("0.15.0");
let new = PublicationId::generate();
let entry = JournalEntry::install(
v("0.15.0"),
Some(old.clone()),
new.clone(),
installation(&v("0.15.0"), &new),
);
prepare(&env.home, &entry).unwrap();
stage_publication(&env.home, &v("0.15.0"), &new);
commit_symlink(&env.home, &entry).unwrap();
let mut state = env.state();
recover(&env.home, &mut state).unwrap();
assert!(
paths::publication_dir(&env.home, &v("0.15.0"), &old).exists(),
"the replaced publication must survive; nothing may be pulled out from under a \
running process"
);
assert!(paths::publication_dir(&env.home, &v("0.15.0"), &new).exists());
}
#[test]
fn a_tombstoned_symlink_completes_the_uninstall() {
let (env, old) = Env::with_installed("0.15.0");
let entry = JournalEntry::uninstall(v("0.15.0"), Some(old.clone()));
prepare(&env.home, &entry).unwrap();
commit_symlink(&env.home, &entry).unwrap();
let mut state = env.state();
recover(&env.home, &mut state).unwrap();
assert!(state.get(&v("0.15.0")).is_none());
assert!(!paths::publication_dir(&env.home, &v("0.15.0"), &old).exists());
assert!(
std::fs::symlink_metadata(paths::toolchain_link(&env.home, &v("0.15.0"))).is_err(),
"the tombstone must be cleaned up once the removal is recorded"
);
assert!(env.journal_is_empty());
}
#[test]
fn an_uncommitted_uninstall_leaves_the_channel_installed() {
let (env, old) = Env::with_installed("0.15.0");
let entry = JournalEntry::uninstall(v("0.15.0"), Some(old.clone()));
prepare(&env.home, &entry).unwrap();
let mut state = env.state();
recover(&env.home, &mut state).unwrap();
assert!(state.get(&v("0.15.0")).is_some());
assert!(paths::publication_dir(&env.home, &v("0.15.0"), &old).exists());
}
#[test]
fn an_absent_journal_recovers_nothing() {
let (env, _) = Env::with_installed("0.15.0");
let mut state = env.state();
assert_eq!(recover(&env.home, &mut state).unwrap(), None);
}
#[test]
fn divergence_without_a_journal_is_reported_not_guessed() {
let (env, old) = Env::with_installed("0.15.0");
std::fs::remove_dir_all(paths::publication_dir(&env.home, &v("0.15.0"), &old)).unwrap();
let mut state = env.state();
let err = recover(&env.home, &mut state).expect_err("must report");
assert!(matches!(err, PublishError::DivergentState { .. }), "{err}");
assert!(
err.to_string().contains("midenup install 0.15.0"),
"the diagnostic must name the exact recovery command: {err}"
);
}
#[test]
fn a_second_operation_cannot_start_while_one_is_journalled() {
let (env, _) = Env::with_installed("0.15.0");
let first = JournalEntry::uninstall(v("0.15.0"), None);
prepare(&env.home, &first).unwrap();
let second = JournalEntry::uninstall(v("0.16.0"), None);
assert!(matches!(
prepare(&env.home, &second),
Err(PublishError::OperationInProgress { .. })
));
prepare(&env.home, &first).expect("re-preparing the same operation must be allowed");
}
}