use std::{
io::{BufRead, Write},
path::Path,
};
use anyhow::Context;
use colored::Colorize;
use crate::{
channel::{Channel, UpstreamChannel, UpstreamMatch, UserChannel},
commands,
config::Config,
manifest::Component,
options::{InstallationOptions, IntentUpdate, PathUpdate, UpdateOptions},
state::{Installation, LocalState},
version::Authority,
};
pub fn update(
config: &Config,
channel_type: Option<&UserChannel>,
state: &mut LocalState,
options: &UpdateOptions,
) -> anyhow::Result<()> {
match channel_type {
Some(UserChannel::Named(name)) => update_network(config, name, state, options),
Some(UserChannel::Version(version)) => {
let installation = state
.get(version)
.cloned()
.context(format!("ERROR: No installed channel found with version {version}"))?;
update_installed_channel(config, &installation, state, options)
},
None => {
if state.installations.is_empty() {
crate::info!("nothing to update: no toolchains are installed");
return Ok(());
}
for installation in state.installations.clone() {
update_installed_channel(config, &installation, state, options)?;
}
Ok(())
},
}
}
fn update_network(
config: &Config,
name: &str,
state: &mut LocalState,
options: &UpdateOptions,
) -> anyhow::Result<()> {
let manifest = config.upstream_manifest()?;
let target = manifest.network_version(name).cloned().with_context(|| {
format!(
"unknown channel '{name}'; known networks are {}",
manifest.network_names().collect::<Vec<_>>().join(", ")
)
})?;
let user_channel = UserChannel::Named(name.to_string().into());
let installed = config.local_channel(&user_channel).with_context(|| {
format!("{name} is not installed. To install it, run:\n midenup install {name}\n")
})?;
let installation = state.get(&installed).cloned().with_context(|| {
format!(
"toolchains/{name} names {installed}, which is not in local state; reinstall with: \
midenup install {name}"
)
})?;
crate::info!("{name} is now {target} (installed as {installed})");
if installed == target {
return update_installed_channel(config, &installation, state, options);
}
let mut upstream = manifest.get_channel_by_name(&target).cloned().with_context(|| {
format!("network '{name}' names channel {target}, which is not upstream")
})?;
if target < installed {
crate::warn!("{name} has moved back from {installed} to {target}.");
}
upstream.sync(config);
install_for_update(
config,
&upstream,
state,
IntentUpdate::Replace(installation.intent.clone()),
Changes::default(),
options,
)?;
let link = crate::paths::network_link(&config.midenup_home, name);
crate::utils::fs::replace_symlink(&link, Path::new(&target.to_string()))
.with_context(|| format!("failed to point '{name}' at {target}"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeClass {
InstallationImpacting,
GraphOnly,
RuntimeMetadataOnly,
None,
}
pub fn classify(old: &Component, new: &Component, target: &str, cwd: &Path) -> ChangeClass {
let key_of = |component: &Component| crate::plan::component_key(component, target, cwd).ok();
match (key_of(old), key_of(new)) {
(Some(old_key), Some(new_key)) if old_key == new_key => {},
_ => return ChangeClass::InstallationImpacting,
}
if old.requires != new.requires || old.profiles != new.profiles {
return ChangeClass::GraphOnly;
}
match (serde_json::to_value(old), serde_json::to_value(new)) {
(Ok(old), Ok(new)) if old == new => ChangeClass::None,
_ => ChangeClass::RuntimeMetadataOnly,
}
}
#[derive(Debug, Default, Clone)]
struct Changes {
stale: Vec<String>,
held_back: Vec<Component>,
logical_only: bool,
}
fn update_installed_channel(
config: &Config,
installation: &Installation,
state: &mut LocalState,
options: &UpdateOptions,
) -> anyhow::Result<()> {
config.upstream_manifest()?;
let local_channel = installation.as_channel();
let Some(upstream) = local_channel.find_upstream_counterpart(config) else {
crate::info!(
"channel {} is not in the upstream manifest; leaving it as installed",
installation.channel
);
return Ok(());
};
match migration_of(&upstream, installation) {
Some(old_channel) => migrate(config, installation, &upstream.channel, state, options)
.with_context(|| format!("failed to migrate channel {old_channel}")),
None => {
let Some(changes) = changes_for(config, installation, &upstream.channel, options)?
else {
crate::info!(
"Aborting update of {} due to user input/configuration",
installation.channel
);
return Ok(());
};
install_for_update(
config,
&upstream.channel,
state,
IntentUpdate::Preserve,
changes,
options,
)
},
}
}
fn migration_of(
upstream: &UpstreamChannel,
installation: &Installation,
) -> Option<semver::Version> {
match &upstream.upstream_match {
UpstreamMatch::UpstreamCounterpart => None,
UpstreamMatch::Migrated { old_channel } => {
(upstream.channel.name != installation.channel).then(|| old_channel.clone())
},
}
}
fn migrate(
config: &Config,
installation: &Installation,
upstream: &Channel,
state: &mut LocalState,
options: &UpdateOptions,
) -> anyhow::Result<()> {
crate::warn!("migrating {} to {}", installation.channel, upstream.name);
install_for_update(
config,
upstream,
state,
IntentUpdate::Replace(installation.intent.clone()),
Changes::default(),
options,
)?;
carry_var_to(&config.midenup_home, &installation.channel, &upstream.name)?;
commands::uninstall(
config,
&crate::channel::UserChannel::Version(installation.channel.clone()),
state,
false,
)
}
fn changes_for(
config: &Config,
installation: &Installation,
upstream: &Channel,
options: &UpdateOptions,
) -> anyhow::Result<Option<Changes>> {
let mut changes = Changes::default();
let cwd = &config.working_directory;
for installed in &installation.components {
let Some(upstream_component) = upstream.get_component(&installed.name) else {
continue;
};
match classify(installed, upstream_component, config.target(), cwd) {
ChangeClass::None => continue,
ChangeClass::GraphOnly | ChangeClass::RuntimeMetadataOnly => {
changes.logical_only = true;
},
ChangeClass::InstallationImpacting => match update_decision(installed, options)? {
ComponentUpdateDecision::Abort => return Ok(None),
ComponentUpdateDecision::Keep => changes.held_back.push(installed.clone()),
ComponentUpdateDecision::Update => changes.stale.push(installed.name.to_string()),
},
}
}
Ok(Some(changes))
}
fn install_for_update(
config: &Config,
upstream: &Channel,
state: &mut LocalState,
intent_update: IntentUpdate,
changes: Changes,
options: &UpdateOptions,
) -> anyhow::Result<()> {
let logical_only = changes.logical_only;
let install_options = InstallationOptions {
stale: changes.stale,
held_back: changes.held_back,
intent_update: Some(intent_update),
..Default::default()
};
match work_for(upstream, state, &install_options, logical_only)? {
Work::Physical => {
display_warnings(upstream, &install_options, options);
crate::info!("Updating toolchain {}..", upstream.name);
commands::install(config, upstream, state, &install_options)
},
Work::LogicalOnly => {
crate::info!("Updating recorded metadata for toolchain {}..", upstream.name);
record_logical_changes(config, upstream, state, &install_options)
},
Work::Nothing => {
crate::info!("Toolchain {} is up to date", upstream.name);
Ok(())
},
}
}
enum Work {
Physical,
LogicalOnly,
Nothing,
}
fn work_for(
upstream: &Channel,
state: &LocalState,
options: &InstallationOptions,
logical_only: bool,
) -> anyhow::Result<Work> {
if !options.stale.is_empty() {
return Ok(Work::Physical);
}
let Some(installed) = state.get(&upstream.name) else {
return Ok(Work::Physical);
};
let intent = commands::install::effective_intent(state, upstream, options);
let resolved = crate::resolve::resolve(upstream, &intent)?;
let installed_names: std::collections::BTreeSet<&str> =
installed.components.iter().map(|component| component.name.as_ref()).collect();
let resolved_names: std::collections::BTreeSet<&str> =
resolved.iter().map(|component| component.name.as_ref()).collect();
if installed_names != resolved_names {
return Ok(Work::Physical);
}
if logical_only || installed.intent != intent {
return Ok(Work::LogicalOnly);
}
Ok(Work::Nothing)
}
fn record_logical_changes(
config: &Config,
upstream: &Channel,
state: &mut LocalState,
options: &InstallationOptions,
) -> anyhow::Result<()> {
let intent = commands::install::effective_intent(state, upstream, options);
let installation = state
.get_mut(&upstream.name)
.with_context(|| format!("channel {} is not installed", upstream.name))?;
installation.intent = intent;
for component in installation.components.iter_mut() {
let Some(upstream_component) = upstream.get_component(&component.name) else {
continue;
};
if options.held_back.iter().any(|held| held.name == component.name) {
continue;
}
let pinned = component.version.clone();
*component = upstream_component.clone();
component.version = pinned;
}
config.write_local_state(state)
}
fn carry_var_to(home: &Path, from: &semver::Version, to: &semver::Version) -> anyhow::Result<()> {
if from == to {
return Ok(());
}
let source = crate::paths::var_dir(home, &UserChannel::Version(from.clone()));
let destination = crate::paths::var_dir(home, &UserChannel::Version(to.clone()));
if !source.is_dir() {
return Ok(());
}
if destination.exists() {
crate::warn!(
"var/{from} was left in place: you already have data at var/{to}, and the two are not \
merged."
);
return Ok(());
}
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create '{}'", parent.display()))?;
}
std::fs::rename(&source, &destination).with_context(|| {
format!("failed to move '{}' to '{}'", source.display(), destination.display())
})
}
#[derive(Debug, PartialEq, Eq)]
enum InteractiveResult {
Cancel,
UpdateComponent,
DontUpdateComponent,
}
fn handle_path_uninstall_interactive(component: &Component) -> anyhow::Result<InteractiveResult> {
let stdin = std::io::stdin();
let mut input = stdin.lock();
crate::report::with_stderr_interaction(|output| {
handle_path_uninstall_interactive_with_io(component, &mut input, output)
})
}
fn handle_path_uninstall_interactive_with_io<R: BufRead, W: Write + ?Sized>(
component: &Component,
input: &mut R,
output: &mut W,
) -> anyhow::Result<InteractiveResult> {
let component_name = &component.name;
writeln!(
output,
"Would you like to update this component? (N/y/c)
- N: no, skip this component
- y: yes, update this component
- c: cancel the update all-together (no changes will be applied)"
)
.context("failed to write update prompt")?;
output.flush().context("failed to flush update prompt")?;
let mut response = String::new();
input.read_line(&mut response).context("failed to read update response")?;
let response = response.trim().to_ascii_lowercase();
let result = match response.as_str() {
"y" => {
writeln!(output, "Updating {component_name}")
.context("failed to write update response")?;
InteractiveResult::UpdateComponent
},
"c" => {
writeln!(output, "Cancelling update, no changes will be applied.")
.context("failed to write update response")?;
InteractiveResult::Cancel
},
_ => {
writeln!(output, "Skipping {component_name}, it will not be updated")
.context("failed to write update response")?;
InteractiveResult::DontUpdateComponent
},
};
output.flush().context("failed to flush update response")?;
Ok(result)
}
enum ComponentUpdateDecision {
Abort,
Keep,
Update,
}
fn update_decision(
component: &Component,
options: &UpdateOptions,
) -> anyhow::Result<ComponentUpdateDecision> {
match &component.version {
Authority::Registry { .. } | Authority::Git { .. } => Ok(ComponentUpdateDecision::Update),
Authority::Path { .. } => match options.path_update {
PathUpdate::Interactive => match handle_path_uninstall_interactive(component)? {
InteractiveResult::Cancel => Ok(ComponentUpdateDecision::Abort),
InteractiveResult::UpdateComponent => Ok(ComponentUpdateDecision::Update),
InteractiveResult::DontUpdateComponent => Ok(ComponentUpdateDecision::Keep),
},
PathUpdate::All => Ok(ComponentUpdateDecision::Update),
PathUpdate::Off => Ok(ComponentUpdateDecision::Keep),
},
}
}
fn display_warnings(
upstream: &Channel,
install_options: &InstallationOptions,
options: &UpdateOptions,
) {
let components_from_path: Vec<String> = upstream
.components
.iter()
.filter_map(|component| match &component.version {
Authority::Path { path, .. } => Some((path, component.crate_name()?)),
_ => None,
})
.map(|(path, crate_name)| {
format!("- {} is installed from {}.\n", crate_name.bold(), path.display())
})
.collect();
if components_from_path.is_empty() {
return;
}
crate::report::prepare_stderr_color();
let guidance = if matches!(options.path_update, PathUpdate::Off)
&& !install_options.held_back.is_empty()
{
"
To make midenup update them all, pass the '--path-update=all' flag to `midenup update`.
Alternatively, pass the '--path-update=interactive' flag to interactively select which \
path-managed components to update."
} else {
""
};
let listed = components_from_path.concat();
crate::warn!(
"the following elements are installed from a specific path in the filesystem.{guidance}
{listed}"
);
}
#[cfg(test)]
mod tests {
use std::borrow::Cow;
use super::*;
use crate::{
artifact::{Artifact, Artifacts},
manifest::{ComponentKind, ExecutableComponent, InstallationMethod},
profile::Profile,
version::Authority,
};
const TARGET: &str = "aarch64-apple-darwin";
fn cwd() -> &'static Path {
Path::new(".")
}
fn base() -> Component {
let mut artifacts = Artifacts::default();
artifacts.insert(
"miden-vm".to_string(),
Artifact::TargetAgnostic {
uri: "https://example.invalid/v1/miden-vm".to_string(),
digest: None,
archive: None,
extra: Default::default(),
},
);
Component {
name: Cow::Borrowed("vm"),
version: Authority::Registry { version: semver::Version::new(0, 1, 0) },
kind: ComponentKind::Executable {
installation_method: InstallationMethod::Prebuilt,
spec: ExecutableComponent {
installed_executable: "miden-vm".to_string(),
..Default::default()
},
},
profiles: vec![Profile::Minimal],
requires: vec![],
artifacts,
extra: Default::default(),
}
}
fn classify_pair(old: Component, new: Component) -> ChangeClass {
classify(&old, &new, TARGET, cwd())
}
fn spec_of(component: &mut Component) -> &mut ExecutableComponent {
match &mut component.kind {
ComponentKind::Executable { spec, .. } => spec,
_ => unreachable!("the fixture is an executable"),
}
}
#[test]
fn an_identical_component_has_not_changed() {
assert_eq!(classify_pair(base(), base()), ChangeClass::None);
}
#[test]
fn an_artifact_only_change_is_installation_impacting() {
let mut new = base();
new.artifacts.insert(
"miden-vm".to_string(),
Artifact::TargetAgnostic {
uri: "https://example.invalid/v2/miden-vm".to_string(),
digest: None,
archive: None,
extra: Default::default(),
},
);
assert_eq!(classify_pair(base(), new), ChangeClass::InstallationImpacting);
}
#[test]
fn a_version_change_is_installation_impacting() {
let mut new = base();
new.version = Authority::Registry { version: semver::Version::new(0, 2, 0) };
assert_eq!(classify_pair(base(), new), ChangeClass::InstallationImpacting);
}
#[test]
fn a_symlink_layout_change_is_installation_impacting() {
let mut new = base();
spec_of(&mut new).symlink_name = Some("miden-vm-next".to_string());
assert_eq!(classify_pair(base(), new), ChangeClass::InstallationImpacting);
}
#[test]
fn an_alias_only_change_is_runtime_metadata_only() {
let mut new = base();
spec_of(&mut new)
.aliases
.insert("run".to_string(), crate::exec::Executable::default_call_format());
assert_eq!(classify_pair(base(), new), ChangeClass::RuntimeMetadataOnly);
}
#[test]
fn a_requires_only_change_is_graph_only() {
let mut new = base();
new.requires = vec!["core".to_string()];
assert_eq!(classify_pair(base(), new), ChangeClass::GraphOnly);
}
#[test]
fn a_profiles_only_change_is_graph_only() {
let mut new = base();
new.profiles = vec![Profile::Complete];
assert_eq!(classify_pair(base(), new), ChangeClass::GraphOnly);
}
#[test]
fn interactive_path_updates_write_and_flush_their_ui() {
for (input, expected, acknowledgement) in [
("y\n", InteractiveResult::UpdateComponent, "Updating vm"),
("c\n", InteractiveResult::Cancel, "Cancelling update"),
("n\n", InteractiveResult::DontUpdateComponent, "Skipping vm"),
] {
let mut input = std::io::Cursor::new(input);
let mut output = FlushTrackingWriter::default();
let result =
handle_path_uninstall_interactive_with_io(&base(), &mut input, &mut output)
.expect("interaction must succeed");
assert_eq!(result, expected);
let rendered = String::from_utf8(output.bytes).unwrap();
assert!(rendered.contains("Would you like to update this component?"));
assert!(rendered.contains(acknowledgement), "missing acknowledgement: {rendered}");
assert_eq!(output.flushes, 2, "prompt and acknowledgement must both be flushed");
}
}
#[derive(Default)]
struct FlushTrackingWriter {
bytes: Vec<u8>,
flushes: usize,
}
impl std::io::Write for FlushTrackingWriter {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.bytes.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
self.flushes += 1;
Ok(())
}
}
}