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 },
}
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;
}
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)
}
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(", ")
}
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()
});
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(())
}
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:?}");
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()
}
#[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");
}
#[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"]);
}
#[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");
}
#[test]
fn the_rendered_script_compiles_and_runs() {
let temp = tempdir::TempDir::new("extract-compile").expect("temp dir");
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"
);
}
}