midenup 1.1.0

The Miden toolchain manager
use clap::Parser;
use midenup::commands::Midenup;

mod common;

use common::*;

/// Checks that the `miden` utility is able to recognize when the currently active toolchain is
/// not installed, and then installing it before executing the passed in command.
#[test]
fn integration_miden_test() {
    let _guard = common::harness::mutating_test_guard();
    let test_name = "integration_miden_test";
    let test_env = environment_setup(test_name);

    // SIDENOTE: This tests uses a toolchain with version number 0.14.0. This
    // is simply used for testing purposes and is not a "real" toolchain.
    const FILE: &str =
        full_path_manifest!("tests/data/integration_miden_test/channel-manifest.json");

    let (mut local_manifest, config) = test_setup(&test_env, FILE);
    let toolchain_dir = test_env.midenup_home.join("toolchains");

    // By default, the active toolchain is the latest stable version. In the
    // case of the manifest present in FILE, that is version 0.16.0.
    let command = Midenup::try_parse_from(["miden", "help", "client"]).unwrap();
    command
        .execute_with_state(&config, &mut local_manifest)
        .expect("failed to get client version");

    // After this, `midenup` should:
    // 1. Recognize that the user wants to run a component
    // 2. Recognize that the active toolchain is not installed, and thus trigger an installation
    // 3. Before issuing the install, it should recognize that midenup hasn't been initialized and
    //    thus needs to be initialized.

    // midenup initialized check
    assert!(test_env.midenup_home.exists());
    assert!(toolchain_dir.exists());
    // The miden symlink should be in $CARGO_HOME/bin
    assert!(test_env.cargo_home.join("bin").join("miden").exists());

    // Stable toolchain installed check
    let latest_toolchain = toolchain_dir.join("0.16.0");
    assert!(latest_toolchain.exists());

    // Symlink check
    let mainnet_dir = toolchain_dir.join("mainnet");
    assert!(mainnet_dir.exists());
    assert!(mainnet_dir.is_symlink());

    // Global default

    // Now, we set a global default toolchain. This should change the current active toolchain
    // to 0.15.0.
    let command = Midenup::try_parse_from(["midenup", "override", "0.15.0"]).unwrap();
    command
        .execute_with_state(&config, &mut local_manifest)
        .expect("failed to override toolchain");

    // This should also trigger an install, since toolchain 0.15.0 is missing and is now the
    // active toolchain.
    let command = Midenup::try_parse_from(["miden", "help", "client"]).unwrap();
    command
        .execute_with_state(&config, &mut local_manifest)
        .expect("failed to get client version");

    let older_toolchain = toolchain_dir.join("0.15.0");
    assert!(older_toolchain.exists());

    // Directory only toolchain
    //
    // Now, we'll create a `miden-toolchain.toml` file. This will change the current active
    // toolchain. By default, the active toolchain is the latest stable version. In the case of
    // the manifest present in FILE, that is version 0.16.0.
    let command = Midenup::try_parse_from(["midenup", "set", "0.14.0"]).unwrap();
    command
        .execute_with_state(&config, &mut local_manifest)
        .expect("failed to set local toolchain");

    // This should also trigger an install, since toolchain 0.14.0 is now missing
    let command = Midenup::try_parse_from(["miden", "help", "client"]).unwrap();
    command
        .execute_with_state(&config, &mut local_manifest)
        .expect("failed to get client version");

    let oldest_toolchain = toolchain_dir.join("0.14.0");
    assert!(oldest_toolchain.exists());

    // Afterwards, all of the newly installed toolchains should be present in the local
    // manifest.
    let installed_toolchains = ["0.14.0", "0.15.0", "0.16.0"].iter().map(|version| {
        semver::Version::parse(version)
            .unwrap_or_else(|_| panic!("Failed to turn {version} into semver::Version"))
    });

    // Besides creating the various directories, the local manifest should also reflect this
    // structure
    local_manifest
        .installations
        .iter()
        .map(|i| i.as_channel())
        .map(|channel| channel.name.clone())
        .eq(installed_toolchains);
}

#[test]
#[cfg(unix)]
fn integration_miden_preserves_component_exit_code() {
    let _guard = common::harness::mutating_test_guard();
    let test_env = environment_setup("integration_miden_preserves_component_exit_code");
    let fixture = common::harness::OfflineFixture::create(test_env.tmp_dir.path(), "0.15.0");
    let vm_binary = fixture.dir.join("0.15.0").join("miden-vm");

    std::fs::write(&vm_binary, b"#!/bin/sh\nexit 42\n").expect("failed to write fixture binary");
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&vm_binary, std::fs::Permissions::from_mode(0o755))
            .expect("failed to chmod fixture binary");
    }

    let installed = run_midenup(&test_env, &fixture.manifest_uri, &["install", "0.15.0"]);
    assert!(installed.status.success(), "{}", String::from_utf8_lossy(&installed.stderr));

    let output = midenup_command(
        test_env.cargo_home.join("bin").join("miden"),
        &test_env,
        &fixture.manifest_uri,
    )
    .arg("vm")
    .output()
    .expect("failed to run miden");

    assert_eq!(output.status.code(), Some(42));
}

/// Checks that the `miden` utility recognizes the existence of a `miden-toolchain.toml` file.
///
/// This file contains the required toolchain for the current project, along with a list of
/// required components. `miden` should be able to:
///
/// - create said file
/// - recognize the list of required components and install them
/// - recognize if the list gets expanded and install the missing components
#[test]
fn integration_miden_toolchain_toml() {
    let _guard = common::harness::mutating_test_guard();
    let test_name = "integration_miden_toolchain_toml";
    let test_env = environment_setup(test_name);

    let pwd = &test_env.present_working_dir;

    const FILE: &str =
        full_path_manifest!("tests/data/integration_miden_toolchain_toml/channel-manifest.json");

    let (mut local_manifest, config) = test_setup(&test_env, FILE);

    // This should create a miden-toolchain.toml file that sets toolchain 0.16.0 as the active
    // one on the current project. Since the toolchain is not installed, the component list is
    // left empty.
    let command = Midenup::try_parse_from(["midenup", "set", "0.16.0"]).unwrap();
    command
        .execute_with_state(&config, &mut local_manifest)
        .expect("failed to set local toolchain");

    // There should now be a `miden-toolchain.toml` file in the PWD.
    let miden_toolchain_file = pwd.join("miden-toolchain.toml");
    assert!(miden_toolchain_file.exists());

    // `miden` should now install the minimal profile components for 0.16.0
    let command = Midenup::try_parse_from(["miden", "help", "toolchain"]).unwrap();
    command
        .execute_with_state(&config, &mut local_manifest)
        .expect("failed to get subcommand help");

    let toolchain_dir = test_env.midenup_home.join("toolchains");
    assert!(toolchain_dir.exists());

    let installed_channel = local_manifest.get(&semver::Version::new(0, 16, 0)).unwrap();
    assert_eq!(installed_channel.components.len(), 1);

    // Now, we'll add the optional debugger component to the list.
    let toolchain_with_components =
        full_path!("tests/data/integration_miden_toolchain_toml/miden-toolchain-2.toml");
    std::fs::copy(toolchain_with_components, miden_toolchain_file).unwrap();

    // `miden` should now install:
    // - The vm and debugger
    // - The core library as it is a dependency for the debugger
    let command = Midenup::try_parse_from(["miden", "help", "toolchain"]).unwrap();
    command
        .execute_with_state(&config, &mut local_manifest)
        .expect("failed to get subcommand help");

    let installed_channel =
        local_manifest.get(&semver::Version::new(0, 16, 0)).unwrap().as_channel();

    // VM, debugger and core library
    assert_eq!(installed_channel.components.len(), 3);

    // Now, we try updating the installed toolchain. An update re-resolves the *recorded* intent,
    // which is `minimal` plus the project's `debug`, so it must not pull in anything else. The
    // toolchain was installed by version, so no network names it here and it is updated by version.
    let command = Midenup::try_parse_from(["midenup", "update", "0.16.0"]).unwrap();
    command
        .execute_with_state(&config, &mut local_manifest)
        .expect("failed to update 0.16.0 toolchain");

    // No components should have been added
    let installed_channel = local_manifest.get(&semver::Version::new(0, 16, 0)).unwrap();
    assert_eq!(installed_channel.components.len(), 3);

    // Finally, we install the entire stable toolchain. This needs `--profile complete`: an install
    // without one records the *minimal* profile, and would shrink the installation rather than
    // grow it (spec section 8.1 -- a direct install replaces intent).
    let command =
        Midenup::try_parse_from(["midenup", "install", "stable", "--profile", "complete"]).unwrap();
    command
        .execute_with_state(&config, &mut local_manifest)
        .expect("failed to install stable toolchain");

    // Now, the entire toolchain should be installed
    let installed_channel = local_manifest.get(&semver::Version::new(0, 16, 0)).unwrap();
    assert_eq!(installed_channel.components.len(), 4);
}

/// This 'midenc' component present in this manifest is lacking its required 'rustup_channel"
/// and thus installation should fail.
#[test]
#[should_panic]
fn integration_midenup_catches_installation_failure() {
    let _guard = common::harness::mutating_test_guard();
    let test_name = "midenup_catches_installation_failure";
    let test_env = environment_setup(test_name);

    const FILE_PRE_UPDATE: &str = full_path_manifest!(
        "tests/data/unit_test_manifest_additional/manifest-uncompilable-midenc.json"
    );

    let (mut local_manifest, config) = test_setup(&test_env, FILE_PRE_UPDATE);

    let command = Midenup::try_parse_from(["midenup", "install", "stable"]).unwrap();
    command
        .execute_with_state(&config, &mut local_manifest)
        .expect("failed to install stable");
    // After install is executed, the local manifest should be present
    let manifest = test_env.midenup_home.join("manifest").with_extension("json");
    assert!(manifest.exists());
}

/// Two projects on one channel must not take components away from each other.
///
/// This is the whole point of recording intent as a union: activating a project's toolchain file
/// adds what that project needs, but a project asking for *less* must never shrink the shared
/// installation. A direct `midenup install` is the only thing allowed to shrink it.
#[test]
fn integration_activation_unions_intent_across_projects() {
    let _guard = common::harness::mutating_test_guard();
    let test_env = environment_setup("integration_activation_unions_intent");
    let fixture = common::harness::OfflineFixture::create(test_env.tmp_dir.path(), "0.15.0");

    let (mut state, config) = test_setup(&test_env, &fixture.manifest_uri);
    let channel = semver::Version::new(0, 15, 0);

    // Project A wants the `assets` component on top of the minimal profile.
    let mut options = midenup::options::InstallationOptions {
        profile: midenup::profile::Profile::Minimal,
        components: vec!["assets".to_string()],
        ..Default::default()
    };
    let upstream = config
        .upstream_manifest()
        .unwrap()
        .get_channel_by_name(&channel)
        .unwrap()
        .clone();
    midenup::commands::install(&config, &upstream, &mut state, &options)
        .expect("failed to install for project A");

    assert!(state.get(&channel).unwrap().intent.roots.contains("assets"));

    // Project B activates with only the minimal profile -- expressed here as the union that
    // activation performs. It must not remove project A's component.
    options.intent_update = Some(midenup::options::IntentUpdate::Union(
        midenup::resolve::Intent::new(&[midenup::profile::Profile::Minimal], &[]),
    ));
    options.components = Vec::new();
    midenup::commands::install(&config, &upstream, &mut state, &options)
        .expect("failed to activate for project B");

    assert!(
        state.get(&channel).unwrap().intent.roots.contains("assets"),
        "activating a project that wants less must not drop another project's component"
    );

    // A direct install, by contrast, states what the user wants now -- and may shrink.
    options.intent_update = None;
    midenup::commands::install(&config, &upstream, &mut state, &options)
        .expect("failed to install directly");

    assert!(
        !state.get(&channel).unwrap().intent.roots.contains("assets"),
        "a direct install replaces intent, so it is allowed to shrink"
    );
}

/// Invoking `miden +channel ...` should override the toolchain selection
#[test]
fn integration_miden_explicit_toolchain_override() {
    let _guard = common::harness::mutating_test_guard();
    let test_env = environment_setup("integration_miden_explicit_toolchain_override");
    let fixture = common::harness::OfflineFixture::new(test_env.tmp_dir.path())
        .with_channel("0.15.0")
        .with_channel("0.16.0")
        .build();

    let (mut state, mut config) = test_setup(&test_env, &fixture.manifest_uri);

    let output_handle = config.capture_output();

    // The default toolchain should be 0.15.0
    {
        let command = Midenup::try_parse_from(["miden", "help", "vm"]).unwrap();
        command
            .execute_with_state(&config, &mut state)
            .expect("failed to execute command with toolchain override");
        let mut output = output_handle.borrow_mut();
        assert!(output.status.success());
        let stdout = core::mem::take(&mut output.stdout);
        output.stderr.clear();
        assert_eq!(core::str::from_utf8(&stdout).unwrap(), "miden-vm 0.15.0\n");
    }

    // With an explicit override, we should be able to pick up 0.16.0 explicitly
    let command = Midenup::try_parse_from(["miden", "+0.16.0", "help", "vm"]).unwrap();
    command
        .execute_with_state(&config, &mut state)
        .expect("failed to execute command with toolchain override");

    let output = output_handle.borrow();
    assert!(output.status.success());
    assert_eq!(core::str::from_utf8(&output.stdout).unwrap(), "miden-vm 0.16.0\n");
}

/// An explicit selection must survive command completion, even when the environment is invalid.
#[test]
fn integration_miden_explicit_override_survives_environment() {
    let test_env = environment_setup("explicit_override_survives_environment");
    let fixture = common::harness::OfflineFixture::new(test_env.tmp_dir.path())
        .with_channel("0.15.0")
        .with_channel("0.16.0")
        .build();
    let binary = env!("CARGO_BIN_EXE_midenup");
    for channel in ["0.15.0", "0.16.0"] {
        let output = midenup_command(binary, &test_env, &fixture.manifest_uri)
            .env_remove("MIDENUP_TOOLCHAIN")
            .args(["install", channel])
            .output()
            .unwrap();
        assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
    }

    for environment in ["", "../invalid", "0.16.0"] {
        let output = midenup_command(binary, &test_env, &fixture.manifest_uri)
            .env_remove("MIDENUP_TOOLCHAIN")
            .args(["override", "0.16.0"])
            .output()
            .unwrap();
        assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));

        let output = midenup_command(
            test_env.cargo_home.join("bin/miden"),
            &test_env,
            &fixture.manifest_uri,
        )
        .env("MIDENUP_TOOLCHAIN", environment)
        .args(["+0.15.0", "help", "vm"])
        .output()
        .unwrap();
        assert_eq!(String::from_utf8_lossy(&output.stdout), "miden-vm 0.15.0\n");
        assert!(
            output.status.success(),
            "command failed with MIDENUP_TOOLCHAIN={environment:?}: {}",
            String::from_utf8_lossy(&output.stderr)
        );
        assert_eq!(
            std::fs::canonicalize(test_env.midenup_home.join("opt")).unwrap(),
            std::fs::canonicalize(test_env.midenup_home.join("toolchains/0.15.0/opt")).unwrap(),
            "the opt link must follow the explicitly selected toolchain"
        );
    }
}