use aube_lockfile::dep_path_filename::dep_path_to_filename;
use miette::{Context, IntoDiagnostic, miette};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub(crate) type PkgJsonCache = BTreeMap<String, Option<serde_json::Value>>;
pub(crate) type WsPkgJsonCache = BTreeMap<PathBuf, Option<serde_json::Value>>;
pub(crate) fn materialized_pkg_dir(
aube_dir: &std::path::Path,
dep_path: &str,
name: &str,
virtual_store_dir_max_length: usize,
placements: Option<&aube_linker::HoistedPlacements>,
) -> std::path::PathBuf {
if let Some(placements) = placements
&& let Some(p) = placements.package_dir(dep_path)
{
return p.to_path_buf();
}
aube_dir
.join(dep_path_to_filename(dep_path, virtual_store_dir_max_length))
.join("node_modules")
.join(name)
}
pub(super) fn dep_modules_dir_for(package_dir: &std::path::Path, name: &str) -> std::path::PathBuf {
if name.starts_with('@') {
package_dir
.parent()
.and_then(std::path::Path::parent)
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| package_dir.to_path_buf())
} else {
package_dir
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| package_dir.to_path_buf())
}
}
fn read_materialized_pkg_json(
aube_dir: &std::path::Path,
dep_path: &str,
name: &str,
virtual_store_dir_max_length: usize,
placements: Option<&aube_linker::HoistedPlacements>,
) -> miette::Result<Option<serde_json::Value>> {
let pkg_dir = materialized_pkg_dir(
aube_dir,
dep_path,
name,
virtual_store_dir_max_length,
placements,
);
let pkg_json_path = pkg_dir.join("package.json");
let content = match std::fs::read_to_string(&pkg_json_path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(miette!(
"failed to read package.json for {name} at {}: {e}",
pkg_json_path.display()
));
}
};
let value = aube_manifest::parse_json::<serde_json::Value>(&pkg_json_path, content)
.map_err(miette::Report::new)
.wrap_err_with(|| format!("failed to parse package.json for {name}"))?;
Ok(Some(value))
}
#[allow(clippy::too_many_arguments)]
fn read_materialized_pkg_json_cached(
cache: &mut PkgJsonCache,
aube_dir: &std::path::Path,
dep_path: &str,
name: &str,
virtual_store_dir_max_length: usize,
placements: Option<&aube_linker::HoistedPlacements>,
) -> miette::Result<Option<serde_json::Value>> {
if let Some(value) = cache.get(dep_path) {
return Ok(value.clone());
}
let value = read_materialized_pkg_json(
aube_dir,
dep_path,
name,
virtual_store_dir_max_length,
placements,
)?;
cache.insert(dep_path.to_string(), value.clone());
Ok(value)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn link_bins_for_dep(
cache: &mut PkgJsonCache,
aube_dir: &std::path::Path,
bin_dir: &std::path::Path,
graph: &aube_lockfile::LockfileGraph,
dep_path: &str,
name: &str,
virtual_store_dir_max_length: usize,
placements: Option<&aube_linker::HoistedPlacements>,
shim_opts: aube_linker::BinShimOptions,
) -> miette::Result<()> {
let pkg_dir = materialized_pkg_dir(
aube_dir,
dep_path,
name,
virtual_store_dir_max_length,
placements,
);
if let Some(pkg_json) = read_materialized_pkg_json_cached(
cache,
aube_dir,
dep_path,
name,
virtual_store_dir_max_length,
placements,
)? && let Some(bin) = pkg_json.get("bin")
{
link_bin_entries(bin_dir, &pkg_dir, Some(name), bin, shim_opts)?;
}
link_bundled_bins(bin_dir, &pkg_dir, graph, dep_path, shim_opts)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(super) fn link_bins(
project_dir: &std::path::Path,
modules_dir_name: &str,
aube_dir: &std::path::Path,
graph: &aube_lockfile::LockfileGraph,
virtual_store_dir_max_length: usize,
placements: Option<&aube_linker::HoistedPlacements>,
shim_opts: aube_linker::BinShimOptions,
cache: &mut PkgJsonCache,
ws_dirs: Option<&BTreeMap<String, PathBuf>>,
ws_cache: &mut WsPkgJsonCache,
) -> miette::Result<()> {
let bin_dir = project_dir.join(modules_dir_name).join(".bin");
std::fs::create_dir_all(&bin_dir).into_diagnostic()?;
for dep in graph.root_deps() {
if let Some(ws_dir) = ws_dirs.and_then(|m| m.get(&dep.name)) {
link_bins_for_workspace_dep(ws_cache, &bin_dir, ws_dir, &dep.name, shim_opts)?;
} else {
link_bins_for_dep(
cache,
aube_dir,
&bin_dir,
graph,
&dep.dep_path,
&dep.name,
virtual_store_dir_max_length,
placements,
shim_opts,
)?;
}
}
Ok(())
}
pub(super) fn link_bins_for_workspace_dep(
cache: &mut WsPkgJsonCache,
bin_dir: &Path,
ws_dir: &Path,
name: &str,
shim_opts: aube_linker::BinShimOptions,
) -> miette::Result<()> {
let pkg_json = if let Some(cached) = cache.get(ws_dir) {
cached.clone()
} else {
let pkg_json_path = ws_dir.join("package.json");
let parsed = match std::fs::read_to_string(&pkg_json_path) {
Ok(content) => Some(
aube_manifest::parse_json::<serde_json::Value>(&pkg_json_path, content)
.map_err(miette::Report::new)
.wrap_err_with(|| {
format!("failed to parse package.json for workspace dep {name}")
})?,
),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return Err(miette!(
"failed to read package.json for workspace dep {name} at {}: {e}",
pkg_json_path.display()
));
}
};
cache.insert(ws_dir.to_path_buf(), parsed.clone());
parsed
};
if let Some(pkg_json) = pkg_json
&& let Some(bin) = pkg_json.get("bin")
{
link_bin_entries(bin_dir, ws_dir, Some(name), bin, shim_opts)?;
}
Ok(())
}
pub(crate) fn link_dep_bins(
aube_dir: &std::path::Path,
graph: &aube_lockfile::LockfileGraph,
virtual_store_dir_max_length: usize,
placements: Option<&aube_linker::HoistedPlacements>,
shim_opts: aube_linker::BinShimOptions,
cache: &mut PkgJsonCache,
) -> miette::Result<()> {
if placements.is_some() {
return Ok(());
}
for (dep_path, pkg) in &graph.packages {
if pkg.dependencies.is_empty() {
continue;
}
let pkg_dir = materialized_pkg_dir(
aube_dir,
dep_path,
&pkg.name,
virtual_store_dir_max_length,
placements,
);
if !pkg_dir.exists() {
continue;
}
let dep_modules_dir = dep_modules_dir_for(&pkg_dir, &pkg.name);
let bin_dir = dep_modules_dir.join(".bin");
for (child_name, child_version) in &pkg.dependencies {
let child_dep_path = format!("{child_name}@{child_version}");
if child_dep_path == *dep_path && child_name == &pkg.name {
continue;
}
link_bins_for_dep(
cache,
aube_dir,
&bin_dir,
graph,
&child_dep_path,
child_name,
virtual_store_dir_max_length,
placements,
shim_opts,
)?;
}
}
Ok(())
}
fn link_bundled_bins(
bin_dir: &std::path::Path,
pkg_dir: &std::path::Path,
graph: &aube_lockfile::LockfileGraph,
dep_path: &str,
shim_opts: aube_linker::BinShimOptions,
) -> miette::Result<()> {
let Some(locked) = graph.get_package(dep_path) else {
return Ok(());
};
for bundled in &locked.bundled_dependencies {
let bundled_dir = pkg_dir.join("node_modules").join(bundled);
let bundled_pkg_json_path = bundled_dir.join("package.json");
let Ok(content) = std::fs::read_to_string(&bundled_pkg_json_path) else {
continue;
};
let Ok(bundled_pkg_json) = serde_json::from_str::<serde_json::Value>(&content) else {
continue;
};
let Some(bin) = bundled_pkg_json.get("bin") else {
continue;
};
link_bin_entries(bin_dir, &bundled_dir, Some(bundled), bin, shim_opts)?;
}
Ok(())
}
pub(super) fn link_bin_entries(
bin_dir: &std::path::Path,
pkg_dir: &std::path::Path,
pkg_name: Option<&str>,
bin: &serde_json::Value,
shim_opts: aube_linker::BinShimOptions,
) -> miette::Result<()> {
match bin {
serde_json::Value::String(bin_path) => {
let Some(name) = pkg_name else {
return Ok(());
};
let bin_name = name.split('/').next_back().unwrap_or(name);
if aube_linker::validate_bin_name(bin_name).is_ok()
&& aube_linker::validate_bin_target(bin_path).is_ok()
{
create_bin_link(bin_dir, bin_name, &pkg_dir.join(bin_path), shim_opts)?;
}
}
serde_json::Value::Object(bins) => {
for (bin_name, path) in bins {
if let Some(path_str) = path.as_str()
&& aube_linker::validate_bin_name(bin_name).is_ok()
&& aube_linker::validate_bin_target(path_str).is_ok()
{
create_bin_link(bin_dir, bin_name, &pkg_dir.join(path_str), shim_opts)?;
}
}
}
_ => {}
}
Ok(())
}
fn create_bin_link(
bin_dir: &std::path::Path,
name: &str,
target: &std::path::Path,
shim_opts: aube_linker::BinShimOptions,
) -> miette::Result<()> {
#[cfg(windows)]
let target_for_mkdir_owned = bin_dir.parent().and_then(|parent| {
let leaf = bin_dir.file_name()?;
let canon = crate::dirs::canonicalize(parent).ok()?;
Some(canon.join(leaf))
});
#[cfg(windows)]
let target_for_mkdir: &std::path::Path = target_for_mkdir_owned.as_deref().unwrap_or(bin_dir);
#[cfg(not(windows))]
let target_for_mkdir = bin_dir;
if let Err(e) = std::fs::create_dir_all(target_for_mkdir) {
let tolerated = e.kind() == std::io::ErrorKind::AlreadyExists && target_for_mkdir.is_dir();
if !tolerated {
return Err(e)
.into_diagnostic()
.wrap_err_with(|| format!("failed to create bin directory {}", bin_dir.display()));
}
}
aube_linker::create_bin_shim(target_for_mkdir, name, target, shim_opts)
.into_diagnostic()
.wrap_err_with(|| {
format!(
"failed to link bin `{name}` at {} -> {}",
bin_dir.join(name).display(),
target.display()
)
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use aube_lockfile::{DepType, DirectDep, LockedPackage, LockfileGraph};
fn locked(name: &str, version: &str, bin: BTreeMap<String, String>) -> LockedPackage {
LockedPackage {
name: name.to_string(),
version: version.to_string(),
dep_path: format!("{name}@{version}"),
bin,
..Default::default()
}
}
#[test]
fn link_bins_reads_manifest_when_lockfile_metadata_is_mixed() {
let dir = tempfile::tempdir().unwrap();
let project_dir = dir.path();
let aube_dir = project_dir.join("node_modules/.aube");
let dep_path = "vitepress@1.6.4";
let pkg_dir = materialized_pkg_dir(&aube_dir, dep_path, "vitepress", 120, None);
std::fs::create_dir_all(pkg_dir.join("bin")).unwrap();
std::fs::write(
pkg_dir.join("package.json"),
r#"{"name":"vitepress","bin":{"vitepress":"bin/vitepress.js"}}"#,
)
.unwrap();
std::fs::write(pkg_dir.join("bin/vitepress.js"), "#!/usr/bin/env node\n").unwrap();
let mut semver_bin = BTreeMap::new();
semver_bin.insert("semver".to_string(), "bin/semver.js".to_string());
let mut packages = BTreeMap::new();
packages.insert(
dep_path.to_string(),
locked("vitepress", "1.6.4", BTreeMap::new()),
);
packages.insert(
"semver@7.7.4".to_string(),
locked("semver", "7.7.4", semver_bin),
);
let mut importers = BTreeMap::new();
importers.insert(
".".to_string(),
vec![DirectDep {
name: "vitepress".to_string(),
dep_path: dep_path.to_string(),
dep_type: DepType::Dev,
specifier: Some("^1.5.0".to_string()),
}],
);
let graph = LockfileGraph {
importers,
packages,
..Default::default()
};
link_bins(
project_dir,
"node_modules",
&aube_dir,
&graph,
120,
None,
aube_linker::BinShimOptions::default(),
&mut PkgJsonCache::new(),
None,
&mut WsPkgJsonCache::new(),
)
.unwrap();
assert!(project_dir.join("node_modules/.bin/vitepress").exists());
}
}