use std::{
borrow::Cow,
collections::{BTreeMap, BTreeSet, HashSet},
path::{Path, PathBuf},
str::FromStr,
};
use anyhow::{Context, bail};
use colored::Colorize;
use serde::{Deserialize, Serialize};
use crate::{
channel::{Channel, UserChannel},
commands,
config::Config,
manifest::{ComponentKind, InstallationMethod, PackageInstallationMethod},
options::{InstallationOptions, IntentUpdate},
profile::Profile,
resolve::Intent,
state::LocalState,
version::Authority,
};
#[derive(Serialize, Deserialize, Debug)]
pub(crate) struct ToolchainFile {
toolchain: Toolchain,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
patches: BTreeMap<String, Patch>,
}
impl ToolchainFile {
pub fn new(toolchain: Toolchain) -> Self {
let patches = toolchain.patches.clone();
ToolchainFile { toolchain, patches }
}
#[inline]
fn into_toolchain(self) -> Toolchain {
Toolchain { patches: self.patches, ..self.toolchain }
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct Patch {
pub version: Authority,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub crate_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub features: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize, Default, Debug)]
pub struct Toolchain {
pub channel: UserChannel,
pub components: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<Profile>,
#[serde(skip)]
pub patches: BTreeMap<String, Patch>,
}
#[derive(Debug)]
pub enum ToolchainJustification {
MidenToolchainFile { path: PathBuf },
Override,
Requested,
Default,
}
fn active_view(
config: &Config,
state: &LocalState,
channel: &UserChannel,
intent: &Intent,
patches: &BTreeMap<String, Patch>,
) -> Option<Channel> {
let version = config.local_channel(channel)?;
let installation = state
.get(&version)
.filter(|installation| installation.is_managed() && installation.patches == *patches)?;
let installed = installation.as_channel();
let resolved = crate::resolve::resolve(&installed, intent).ok()?;
Some(Channel::new(installed.name.clone(), resolved.into_iter().cloned().collect()))
}
fn ensure_patches_requested<'a>(
patches: &BTreeMap<String, Patch>,
requested: impl IntoIterator<Item = &'a str>,
) -> anyhow::Result<()> {
let requested: HashSet<&str> = requested.into_iter().collect();
if let Some(name) = patches.keys().find(|name| !requested.contains(name.as_str())) {
bail!("cannot patch '{name}': the toolchain does not request it; add it to 'components'");
}
Ok(())
}
pub(crate) fn changed_patches(
a: &BTreeMap<String, Patch>,
b: &BTreeMap<String, Patch>,
) -> Vec<String> {
let changed: BTreeSet<&String> =
a.keys().chain(b.keys()).filter(|name| a.get(*name) != b.get(*name)).collect();
changed.into_iter().cloned().collect()
}
fn apply_patches(channel: &Channel, patches: &BTreeMap<String, Patch>) -> anyhow::Result<Channel> {
let mut patched = channel.clone();
for (name, patch) in patches {
let Some(component) = patched.components.iter_mut().find(|c| c.name == name.as_str())
else {
bail!("cannot patch '{name}': channel {} has no such component", channel.name);
};
match &mut component.kind {
ComponentKind::Executable { installation_method, .. }
| ComponentKind::CargoExtension { installation_method, .. } => {
let (crate_name, rustup_channel, features) = match installation_method.clone() {
InstallationMethod::Cargo { crate_name, rustup_channel, features }
| InstallationMethod::PrebuiltWithCargoFallback {
crate_name,
rustup_channel,
features,
} => (
patch.crate_name.clone().unwrap_or(crate_name),
rustup_channel,
patch.features.clone().unwrap_or(features),
),
InstallationMethod::Prebuilt => {
let Some(crate_name) = patch.crate_name.clone() else {
bail!(
"cannot patch '{name}': it is only published prebuilt, so the \
patch must set 'crate_name'"
);
};
(crate_name, None, patch.features.clone().unwrap_or_default())
},
};
*installation_method =
InstallationMethod::Cargo { crate_name, rustup_channel, features };
},
ComponentKind::LegacyPackage {
installation_method: PackageInstallationMethod::Cargo { crate_name, features, .. },
..
} => {
if let Some(patched_crate) = &patch.crate_name {
*crate_name = patched_crate.clone();
}
if let Some(patched_features) = &patch.features {
*features = patched_features.clone();
}
},
kind => bail!("cannot patch '{name}': a {} is not built with cargo", kind.tag()),
}
component.version = patch.version.clone();
component.artifacts = Default::default();
}
Ok(patched)
}
impl Toolchain {
pub fn new(channel: UserChannel, profile: Option<Profile>, components: Vec<String>) -> Self {
Toolchain {
channel,
components,
profile,
patches: BTreeMap::new(),
}
}
pub fn current(
config: &Config,
toolchain_override: Option<&str>,
) -> anyhow::Result<(Toolchain, ToolchainJustification)> {
let local_toolchain = Self::toolchain_file(&config.working_directory);
let global_toolchain = config.midenup_home.join("toolchains").join("default");
let env_override = std::env::var("MIDENUP_TOOLCHAIN").ok();
if let Some(channel_name) = toolchain_override.or(env_override.as_deref()) {
let channel = channel_name
.parse::<UserChannel>()
.with_context(|| format!("invalid channel name '{channel_name}'"))?;
let toolchain = Toolchain {
channel,
components: vec![],
profile: None,
patches: BTreeMap::new(),
};
Ok((toolchain, ToolchainJustification::Requested))
} else if let Some(local_toolchain) = local_toolchain {
let toolchain_file_contents =
std::fs::read_to_string(&local_toolchain).with_context(|| {
format!("unable to read toolchain file '{}'", local_toolchain.display())
})?;
let toolchain_file: ToolchainFile =
toml::from_str(&toolchain_file_contents).context("invalid toolchain file")?;
let mut current_toolchain = toolchain_file.into_toolchain();
let project_dir = local_toolchain.parent().expect("a file has a parent directory");
for patch in current_toolchain.patches.values_mut() {
if let Authority::Path { path, .. } = &mut patch.version {
*path = project_dir.join(&*path);
}
}
Ok((
current_toolchain,
ToolchainJustification::MidenToolchainFile { path: local_toolchain },
))
} else if let Ok(channel_path) = std::fs::read_link(&global_toolchain) {
let channel_name = channel_path
.file_name()
.and_then(|name| name.to_str())
.context("unable to read channel name from directory")?;
let user_channel = UserChannel::from_str(channel_name)?;
let toolchain = Toolchain {
channel: user_channel,
components: vec![],
profile: None,
patches: BTreeMap::new(),
};
Ok((toolchain, ToolchainJustification::Override))
} else {
Ok((Toolchain::default(), ToolchainJustification::Default))
}
}
pub fn ensure_current_is_installed(
config: &Config,
state: &mut LocalState,
toolchain_override: Option<&str>,
) -> anyhow::Result<(Self, ToolchainJustification, Option<Channel>)> {
let (current_toolchain, justification) = Toolchain::current(config, toolchain_override)?;
let desired_channel = ¤t_toolchain.channel;
let intent = Intent {
profiles: [current_toolchain.profile.unwrap_or_default()].into_iter().collect(),
roots: current_toolchain.components.iter().cloned().collect(),
};
let project_view =
matches!(justification, ToolchainJustification::MidenToolchainFile { .. });
if let Some(view) =
active_view(config, state, desired_channel, &intent, ¤t_toolchain.patches)
{
ensure_patches_requested(
¤t_toolchain.patches,
view.components.iter().map(|component| component.name.as_ref()),
)?;
crate::info!("current toolchain is {desired_channel} and is installed");
return Ok((current_toolchain, justification, project_view.then_some(view)));
}
let manifest = config.upstream_manifest()?;
let Some(upstream) = manifest.get_channel(desired_channel) else {
bail!(
"channel '{}' is set because {}, however the channel doesn't exist or is \
unavailable",
desired_channel,
match justification {
ToolchainJustification::Default => Cow::Borrowed("it is the default"),
ToolchainJustification::MidenToolchainFile { path } => {
Cow::Owned(format!("it is set in {}", path.display()))
},
ToolchainJustification::Requested =>
Cow::Borrowed("it was explicitly requested on the command line"),
ToolchainJustification::Override =>
Cow::Borrowed("it was set using 'midenup set'"),
}
);
};
let channel = &apply_patches(upstream, ¤t_toolchain.patches)?;
let resolved =
crate::resolve::resolve(channel, &intent).with_context(|| match &justification {
ToolchainJustification::MidenToolchainFile { path } => {
format!("unable to resolve the toolchain declared in {}", path.display())
},
_ => format!("unable to resolve the {} toolchain", channel.name),
})?;
ensure_patches_requested(
¤t_toolchain.patches,
resolved.iter().map(|component| component.name.as_ref()),
)?;
let upstream_view = Some(Channel::new(
channel.name.clone(),
resolved.iter().map(|component| (*component).clone()).collect(),
));
let target = match desired_channel {
UserChannel::Version(_) => channel.name.to_string(),
UserChannel::Named(network) => format!("{network} ({})", channel.name),
};
match state.get(&channel.name).filter(|installation| installation.is_managed()) {
Some(installed) if installed.patches != current_toolchain.patches => {
crate::info!("reinstalling the current toolchain {target} to apply its patches");
},
Some(installed) => {
let installed_components: HashSet<&str> =
HashSet::from_iter(installed.components.iter().map(|comp| comp.name.as_ref()));
crate::info!("installing missing components of the current toolchain {target}:");
for component in resolved
.iter()
.map(|component| component.name.as_ref())
.filter(|name| !installed_components.contains(name))
{
crate::note!("- {}", component.bold());
}
},
None => {
crate::info!("current toolchain is {target}, but not yet installed");
},
}
let _lock = crate::lock::acquire(&config.midenup_home)?;
*state = config.local_state()?;
if let Some(view) =
active_view(config, state, desired_channel, &intent, ¤t_toolchain.patches)
{
ensure_patches_requested(
¤t_toolchain.patches,
view.components.iter().map(|component| component.name.as_ref()),
)?;
return Ok((current_toolchain, justification, project_view.then_some(view)));
}
let options = InstallationOptions {
intent_update: Some(IntentUpdate::Union(intent.clone())),
network: match desired_channel {
UserChannel::Named(name) => Some(name.to_string()),
UserChannel::Version(_) => None,
},
patches: current_toolchain.patches.clone(),
..Default::default()
};
commands::install(config, channel, state, &options)?;
Ok((current_toolchain, justification, upstream_view.filter(|_| project_view)))
}
fn toolchain_file(working_directory: &Path) -> Option<PathBuf> {
let mut current_dir = Some(working_directory);
let mut toolchain_file = None;
while let Some(current_path) = current_dir {
let current_file = current_path.join("miden-toolchain").with_extension("toml");
if current_file.exists() {
toolchain_file = Some(current_file);
break;
}
current_dir = current_path.parent();
}
toolchain_file
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::version::GitTarget;
const VM: &str = r#"{"artifacts": {"miden-vm": {"uri": "https://example.invalid/miden-vm"}}, "installation_method": {"kind": "prebuilt"}, "installed-executable": "miden-vm", "kind": "executable", "name": "vm", "profiles": ["minimal"], "version": {"kind": "registry", "version": "0.29.4"}}"#;
const CORE: &str = r#"{"artifacts": {"miden-core.masp": {"uri": "https://example.invalid/core.masp"}}, "kind": "package", "name": "core", "profiles": ["minimal"], "version": {"kind": "registry", "version": "0.29.4"}}"#;
fn channel() -> Channel {
Channel::new(
semver::Version::new(0, 16, 0),
vec![serde_json::from_str(VM).unwrap(), serde_json::from_str(CORE).unwrap()],
)
}
fn git_patch(crate_name: Option<&str>) -> Patch {
Patch {
version: Authority::Git {
repository_url: "https://github.com/0xMiden/miden-vm.git".to_string(),
subpath: None,
target: GitTarget::Tag { name: "v0.22.1".to_string() },
},
crate_name: crate_name.map(String::from),
features: None,
}
}
fn patches(name: &str, patch: Patch) -> BTreeMap<String, Patch> {
[(name.to_string(), patch)].into_iter().collect()
}
#[test]
fn a_patch_outside_the_requested_components_is_rejected() {
let vm_patch = patches("vm", git_patch(Some("miden-vm")));
ensure_patches_requested(&vm_patch, ["vm", "core"]).unwrap();
let err = ensure_patches_requested(&vm_patch, ["core"]).unwrap_err();
assert!(err.to_string().contains("does not request it"), "{err}");
}
#[test]
fn changed_patches_covers_added_removed_and_modified() {
let registry = Patch {
version: Authority::Registry { version: semver::Version::new(0, 29, 4) },
crate_name: None,
features: None,
};
let installed: BTreeMap<String, Patch> = [
("vm".to_string(), git_patch(Some("miden-vm"))),
("midenc".to_string(), registry.clone()),
("debug".to_string(), registry.clone()),
]
.into_iter()
.collect();
let current: BTreeMap<String, Patch> = [
("vm".to_string(), git_patch(Some("miden-vm"))),
("midenc".to_string(), git_patch(Some("midenc"))),
("client".to_string(), registry),
]
.into_iter()
.collect();
assert_eq!(changed_patches(&installed, ¤t), ["client", "debug", "midenc"]);
assert!(changed_patches(&installed, &installed).is_empty());
}
#[test]
fn a_toolchain_file_reads_patches() {
let file: ToolchainFile = toml::from_str(
r#"
[toolchain]
channel = "0.16.0"
components = ["vm"]
[patches.vm]
crate_name = "miden-vm"
features = ["executable"]
version = { kind = "git", repository_url = "https://github.com/0xMiden/miden-vm.git", tag = "v0.22.1" }
"#,
)
.unwrap();
let expected = Patch {
features: Some(vec!["executable".to_string()]),
..git_patch(Some("miden-vm"))
};
assert_eq!(file.into_toolchain().patches, patches("vm", expected));
}
#[test]
fn a_patched_prebuilt_component_becomes_a_cargo_build() {
let patch = Patch {
features: Some(vec!["executable".to_string()]),
..git_patch(Some("miden-vm"))
};
let patched = apply_patches(&channel(), &patches("vm", patch)).unwrap();
let vm = patched.get_component("vm").unwrap();
assert_eq!(vm.version, git_patch(None).version);
assert!(vm.artifacts.is_empty());
assert!(matches!(
vm.kind(),
ComponentKind::Executable {
installation_method: InstallationMethod::Cargo { crate_name, features, .. },
..
} if crate_name == "miden-vm" && features == &["executable"]
));
assert_eq!(patched.get_component("core"), channel().get_component("core"));
}
#[test]
fn patching_a_prebuilt_component_requires_a_crate_name() {
let err = apply_patches(&channel(), &patches("vm", git_patch(None))).unwrap_err();
assert!(err.to_string().contains("crate_name"), "{err}");
}
#[test]
fn patching_an_unknown_component_fails() {
let err =
apply_patches(&channel(), &patches("mv", git_patch(Some("miden-vm")))).unwrap_err();
assert!(err.to_string().contains("no such component"), "{err}");
}
#[test]
fn a_package_cannot_be_patched() {
let err =
apply_patches(&channel(), &patches("core", git_patch(Some("miden-core")))).unwrap_err();
assert!(err.to_string().contains("is not built with cargo"), "{err}");
}
}