midenup 1.1.0

The Miden toolchain manager
//! The one thing that still needs a generated Cargo script.
//!
//! A `legacy-package` is a Miden package that only exists as a value inside a Rust crate: getting
//! it out means compiling an expression against that crate. There is no way to do that from a
//! pre-built binary, so this generates a small `cargo -Zscript` program, declares the crates as
//! dependencies, and writes each package to its planned destination.
//!
//! Everything else -- downloads, local copies, Cargo builds, symlinks -- is done natively. A plan
//! containing no [PlanStep::ExtractPackage] generates no script and never invokes cargo here.
//!
//! This kind is closed to new channels (see [crate::manifest::ComponentKind::LegacyPackage]). When
//! the channels that use it are backfilled with prebuilt artifacts, this module goes with them.

use std::{collections::BTreeMap, ffi::OsString, path::Path};

use crate::plan::{PlanStep, ResolvedAuthority};

#[derive(Debug, thiserror::Error)]
pub enum ExtractError {
    #[error("failed to write the extraction script to '{path}': {source}")]
    Write {
        path: std::path::PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("failed to run the extraction script: {0}")]
    Spawn(String),
    #[error("the extraction script exited with {status}")]
    Failed { status: String },
}

/// Renders a script that extracts every [PlanStep::ExtractPackage] in `steps`.
///
/// Returns `None` when there is nothing to extract, which is the common case: no script is written
/// and cargo is never invoked.
pub fn render_script(steps: &[PlanStep]) -> Option<String> {
    let extractions: Vec<&PlanStep> =
        steps.iter().filter(|s| matches!(s, PlanStep::ExtractPackage { .. })).collect();
    if extractions.is_empty() {
        return None;
    }

    // One dependency per crate, even if several components extract from it.
    let mut dependencies: BTreeMap<String, String> = BTreeMap::new();
    for step in &extractions {
        let PlanStep::ExtractPackage { crate_name, authority, features, .. } = step else {
            continue;
        };
        dependencies.insert(crate_name.clone(), dependency_spec(authority, features));
    }

    let mut script = String::new();
    script.push_str("#!/usr/bin/env cargo\n---cargo\n[package]\nedition = \"2024\"\n\n");
    script.push_str("[dependencies]\n");
    for (name, spec) in &dependencies {
        script.push_str(&format!("{name} = {{ {spec} }}\n"));
    }
    script.push_str("---\n\n// Generated by midenup. Do not edit by hand.\n\n");
    script.push_str("use std::{path::Path, process::ExitCode};\n\nfn main() -> ExitCode {\n");

    for step in &extractions {
        let PlanStep::ExtractPackage { extractor, dest, owner, .. } = step else {
            continue;
        };
        script.push_str(&format!(
            r#"    {{
        let dest = Path::new({dest:?});
        if let Some(parent) = dest.parent() {{
            if let Err(err) = std::fs::create_dir_all(parent) {{
                eprintln!("error: failed to create {{}}: {{err}}", parent.display());
                return ExitCode::FAILURE;
            }}
        }}
        if let Err(err) = {extractor}.write_to_file(&dest) {{
            eprintln!("error: failed to extract {owner}: {{err}}");
            return ExitCode::FAILURE;
        }}
    }}
"#,
            dest = dest.display().to_string(),
        ));
    }

    script.push_str("    ExitCode::SUCCESS\n}\n");
    Some(script)
}

/// Builds the inline-table body of a Cargo dependency.
///
/// Assembled here rather than in a template so that an absent features list simply contributes no
/// entry. A fixed `{ {version}, {features} }` template would leave a trailing comma inside the
/// inline table whenever features are empty, which is not valid TOML.
fn dependency_spec(authority: &ResolvedAuthority, features: &[String]) -> String {
    let mut parts = Vec::with_capacity(2);

    match authority {
        ResolvedAuthority::Registry { version } => parts.push(format!("version = \"{version}\"")),
        ResolvedAuthority::Git { url, revision, .. } => {
            parts.push(format!("git = \"{url}\", rev = \"{revision}\""))
        },
        ResolvedAuthority::Path { canonical, .. } => {
            parts.push(format!("path = \"{}\"", canonical.display()))
        },
    }

    if !features.is_empty() {
        let list = features.iter().map(|f| format!("\"{f}\"")).collect::<Vec<_>>().join(", ");
        parts.push(format!("default-features = false, features = [{list}]"));
    }

    parts.join(", ")
}

/// Writes and runs the extraction script, if the plan needs one.
pub fn extract(steps: &[PlanStep], script_path: &Path, verbose: bool) -> Result<(), ExtractError> {
    let Some(script) = render_script(steps) else {
        return Ok(());
    };

    std::fs::write(script_path, script)
        .map_err(|source| ExtractError::Write { path: script_path.to_path_buf(), source })?;

    let argv = argv_for(script_path, verbose);
    crate::trace!(
        "running: cargo {}",
        argv.iter().map(|a| a.to_string_lossy()).collect::<Vec<_>>().join(" ")
    );

    let mut command = std::process::Command::new("cargo");
    command.args(&argv).stderr(std::process::Stdio::inherit()).stdout(if verbose {
        std::process::Stdio::inherit()
    } else {
        std::process::Stdio::null()
    });

    // One script covers every package in the plan, so the wait belongs to all of them rather than
    // to a component that could be named here.
    let status = crate::install::run_reporting_progress(&mut command, "extracting packages")
        .map_err(|err| ExtractError::Spawn(err.to_string()))?;

    if !status.success() {
        return Err(ExtractError::Failed { status: status.to_string() });
    }
    Ok(())
}

/// The exact argument vector for the extraction run.
///
/// Split out so the shape of the command can be asserted without running a compiler.
fn argv_for(script_path: &Path, verbose: bool) -> Vec<OsString> {
    let mut argv: Vec<OsString> = vec!["+nightly".into(), "-Zscript".into()];
    if !verbose {
        argv.push("--quiet".into());
    }
    argv.push(script_path.into());
    argv
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::*;

    #[test]
    fn the_extraction_script_is_quiet_below_verbose() {
        let quiet = argv_for(Path::new("/s/extract.rs"), false);
        assert!(quiet.iter().any(|a| a == "--quiet"), "{quiet:?}");

        let loud = argv_for(Path::new("/s/extract.rs"), true);
        assert!(!loud.iter().any(|a| a == "--quiet"), "{loud:?}");

        // The script path stays last either way: cargo reads it as the program to run, not as a
        // value for whatever flag precedes it.
        for argv in [quiet, loud] {
            assert_eq!(argv.last().unwrap(), "/s/extract.rs");
            assert_eq!(argv.first().unwrap(), "+nightly");
        }
    }

    fn extraction(crate_name: &str, dest: &str, extractor: &str) -> PlanStep {
        PlanStep::ExtractPackage {
            crate_name: crate_name.to_string(),
            authority: ResolvedAuthority::Registry { version: semver::Version::new(0, 1, 0) },
            features: vec![],
            extractor: extractor.to_string(),
            dest: PathBuf::from(dest),
            owner: "pkg".to_string(),
        }
    }

    fn frontmatter(script: &str) -> String {
        let body = script.split_once("---cargo\n").expect("no frontmatter").1;
        body.split_once("\n---").expect("unterminated frontmatter").0.to_string()
    }

    /// The common case: nothing to extract, so no script and no cargo invocation.
    #[test]
    fn no_extract_steps_means_no_script() {
        assert!(render_script(&[]).is_none());

        let download = PlanStep::Download {
            uri: "https://example.invalid/x".to_string(),
            dest: PathBuf::from("/tmp/x"),
            mode: 0o755,
            owner: "x".to_string(),
            digest: None,
            archive: None,
            fallback: None,
        };
        assert!(render_script(&[download]).is_none(), "downloads need no script");
    }

    /// An absent features list contributes no entry at all, so the inline table carries no
    /// trailing comma and the frontmatter parses whatever the authority is.
    #[test]
    fn the_frontmatter_is_valid_toml_for_every_authority() {
        let authorities = [
            (
                "registry",
                ResolvedAuthority::Registry { version: semver::Version::new(0, 1, 0) },
            ),
            (
                "git",
                ResolvedAuthority::Git {
                    url: "https://example.invalid/r.git".to_string(),
                    revision: "abc123".to_string(),
                    subpath: None,
                },
            ),
            (
                "path",
                ResolvedAuthority::Path {
                    canonical: PathBuf::from("/src/pkg"),
                    mtime: None,
                },
            ),
        ];

        for (label, authority) in authorities {
            for features in [vec![], vec!["std".to_string()]] {
                let step = PlanStep::ExtractPackage {
                    crate_name: "fixture-pkg".to_string(),
                    authority: authority.clone(),
                    features: features.clone(),
                    extractor: "x()".to_string(),
                    dest: PathBuf::from("/tmp/p.masp"),
                    owner: "pkg".to_string(),
                };
                let script = render_script(&[step]).expect("a script is expected");
                let fm = frontmatter(&script);
                toml::from_str::<toml::Value>(&fm).unwrap_or_else(|err| {
                    panic!("{label} with {features:?}: invalid TOML: {err}\n---\n{fm}\n---")
                });
            }
        }
    }

    #[test]
    fn features_are_carried_into_the_dependency() {
        let step = PlanStep::ExtractPackage {
            crate_name: "fixture-pkg".to_string(),
            authority: ResolvedAuthority::Registry { version: semver::Version::new(0, 1, 0) },
            features: vec!["std".to_string(), "concurrent".to_string()],
            extractor: "x()".to_string(),
            dest: PathBuf::from("/tmp/p.masp"),
            owner: "pkg".to_string(),
        };
        let script = render_script(&[step]).unwrap();
        let parsed: toml::Value = toml::from_str(&frontmatter(&script)).unwrap();
        let dep = &parsed["dependencies"]["fixture-pkg"];

        assert_eq!(dep["default-features"].as_bool(), Some(false));
        let features: Vec<&str> = dep["features"]
            .as_array()
            .unwrap()
            .iter()
            .map(|f| f.as_str().unwrap())
            .collect();
        assert_eq!(features, vec!["std", "concurrent"]);
    }

    /// Several components extracting from one crate must declare it once.
    #[test]
    fn a_shared_crate_is_declared_once() {
        let steps = vec![
            extraction("fixture-pkg", "/tmp/a.masp", "a()"),
            extraction("fixture-pkg", "/tmp/b.masp", "b()"),
        ];
        let script = render_script(&steps).unwrap();
        let parsed: toml::Value = toml::from_str(&frontmatter(&script)).unwrap();

        assert_eq!(parsed["dependencies"].as_table().unwrap().len(), 1);
        assert!(script.contains("a.masp") && script.contains("b.masp"), "both must be extracted");
    }

    /// Render *and compile* the script: a rendering mistake only surfaces once cargo parses and
    /// builds it. A local path dependency keeps that offline and fast.
    #[test]
    fn the_rendered_script_compiles_and_runs() {
        let temp = tempdir::TempDir::new("extract-compile").expect("temp dir");

        // A stand-in crate exposing just enough surface for an extractor expression.
        let krate = temp.path().join("fixture-pkg");
        std::fs::create_dir_all(krate.join("src")).unwrap();
        std::fs::write(
            krate.join("Cargo.toml"),
            "[package]\nname = \"fixture-pkg\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
        )
        .unwrap();
        std::fs::write(
            krate.join("src").join("lib.rs"),
            r#"
pub struct Lib;
impl Lib {
    #[allow(clippy::should_implement_trait)]
    pub fn default() -> Self { Lib }
    pub fn as_ref(&self) -> &Self { self }
    pub fn write_to_file(&self, path: &std::path::Path) -> Result<(), String> {
        std::fs::write(path, b"extracted").map_err(|err| err.to_string())
    }
}
"#,
        )
        .unwrap();

        let dest = temp.path().join("out").join("pkg.masp");
        let step = PlanStep::ExtractPackage {
            crate_name: "fixture-pkg".to_string(),
            authority: ResolvedAuthority::Path { canonical: krate, mtime: None },
            features: vec![],
            extractor: "fixture_pkg::Lib::default().as_ref()".to_string(),
            dest: dest.clone(),
            owner: "pkg".to_string(),
        };

        let script_path = temp.path().join("extract.rs");
        extract(&[step], &script_path, true).expect("the generated script must compile and run");

        assert_eq!(
            std::fs::read(&dest).unwrap(),
            b"extracted",
            "the package must land at its planned destination, with parents created"
        );
    }
}