midenup 1.0.1

The Miden toolchain manager
Documentation
use anyhow::{Context, bail};

use crate::{
    channel::UserChannel,
    config::Config,
    paths,
    publish::JournalEntry,
    state::{LocalState, PublicationRef},
};

/// Removes an installed channel, or only a network's link to it when other networks on this
/// machine still name the same channel.
///
/// Uses the same journalled sequence as install (spec section 9.5), with one difference: the commit
/// point replaces `toolchains/<channel>` with a **tombstone** rather than repointing it. That is
/// what lets recovery tell a removal that was committed and interrupted apart from a toolchain
/// somebody deleted by hand -- the first is completed, the second is reported.
///
/// The publication is removed wholesale. There is no per-component removal pass: the publication
/// directory contains exactly what its receipt says it does and nothing else, so walking components
/// to delete their files individually could only ever remove less than the directory itself.
///
/// `var/` is **kept** unless `purge` is given. It is the user's data -- the client's database lives
/// there -- and removing a toolchain is not a request to delete it. The user is told where it was
/// left.
///
/// What a purge removes is keyed by `requested`, exactly as the user spelled it, because that is
/// how the store is keyed ([`crate::paths::var_dir`]): `uninstall mainnet --purge` removes
/// `var/mainnet`, and `uninstall 0.15.0 --purge` removes `var/0.15.0`. Removing a channel therefore
/// never touches a network's store, which is correct -- the network outlives any channel it names,
/// and its data belongs to the network.
pub fn uninstall(
    config: &Config,
    requested: &UserChannel,
    state: &mut LocalState,
    purge: bool,
) -> anyhow::Result<()> {
    // Resolved against local state, never upstream: a channel that has been withdrawn upstream is
    // precisely one a user needs to be able to remove (spec section 12.3).
    let installed = config.local_channel(requested).and_then(|version| state.get(&version));

    let Some(installation) = installed else {
        bail!("channel {requested} is not installed, nothing to uninstall");
    };
    let channel = installation.channel.clone();
    let home = &config.midenup_home;

    // The network to unlink when the channel it names stays installed for other networks.
    let shared_network = match requested {
        UserChannel::Named(network)
            if crate::networks::links(home)
                .into_iter()
                .any(|(other, linked)| other != network.as_ref() && linked == channel) =>
        {
            Some(network.as_ref())
        },
        _ => None,
    };

    if let Some(network) = shared_network {
        let link = paths::network_link(home, network);
        crate::trace!("removing {}", link.display());
        std::fs::remove_file(link)?;
    } else {
        let publication = match &installation.publication {
            PublicationRef::Managed { id, .. } => Some(id.clone()),
            // Carried over from v1: nothing describes what it owns, so there is no publication to
            // reclaim. The state record still goes.
            PublicationRef::NeedsReinstall => None,
        };

        let entry = JournalEntry::uninstall(channel.clone(), publication);
        crate::publish::journal::prepare(home, &entry)?;

        // The commit point: after this the channel is uninstalled, and an interrupted run is
        // completed rather than rolled back. Nothing happens between preparing the journal and
        // this, so a prepared uninstall is never discarded.
        crate::publish::journal::commit_symlink(home, &entry)?;

        // Removes the state record, reclaims the publication, clears the tombstone and the network
        // links naming the channel, deletes the journal.
        crate::publish::journal::finish(home, &entry, state)?;
    }

    // `default` is the user's `midenup override` choice, so nothing recomputes it. Either override
    // form dangles once what it names is gone: one names a network link that has just been
    // removed, the other names the toolchain directory the commit tombstoned. Testing for dangling
    // covers both, and repairs one left over from any earlier cause.
    let default = paths::toolchains_dir(home).join("default");
    if std::fs::symlink_metadata(&default).is_ok() && default.canonicalize().is_err() {
        std::fs::remove_file(&default)?;
        crate::info!(
            "removed the 'default' override, which named {requested}. Set a new one with:\n    \
             midenup override <channel>"
        );
    }

    // Only now, and only if asked. Deliberately after the commit: this is the one part of an
    // uninstall that cannot be undone by reinstalling.
    let var = paths::var_dir(home, requested);
    if var.is_dir() {
        if purge {
            crate::trace!("removing {}", var.display());
            std::fs::remove_dir_all(&var)
                .with_context(|| format!("failed to remove '{}'", var.display()))?;
        } else {
            crate::info!(
                "kept your data for {requested} at {}; remove it with `midenup uninstall \
                 {requested} --purge`",
                var.display()
            );
        }
    }

    if shared_network.is_some() {
        crate::info!(
            "removed network '{requested}'; channel '{channel}' stays installed for the other \
             networks that name it"
        );
    } else {
        crate::info!("uninstalled channel '{channel}'");
    }

    Ok(())
}