use crate::{DepType, DirectDep, Error, GitSource, LocalSource, LockedPackage, LockfileGraph};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::path::{Path, PathBuf};
#[derive(Debug, Deserialize)]
struct RawNpmLockfile {
#[serde(rename = "lockfileVersion")]
lockfile_version: u32,
#[serde(default)]
packages: BTreeMap<String, RawNpmPackage>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawNpmPackage {
#[serde(default)]
name: Option<String>,
#[serde(default)]
version: Option<String>,
#[serde(default)]
integrity: Option<String>,
#[serde(default)]
resolved: Option<String>,
#[serde(default)]
link: bool,
#[serde(default)]
dependencies: BTreeMap<String, String>,
#[serde(default)]
dev_dependencies: BTreeMap<String, String>,
#[serde(default)]
optional_dependencies: BTreeMap<String, String>,
#[serde(default)]
peer_dependencies: BTreeMap<String, String>,
#[serde(default)]
peer_dependencies_meta: BTreeMap<String, RawNpmPeerDepMeta>,
#[serde(default, deserialize_with = "aube_util::string_or_seq")]
os: Vec<String>,
#[serde(default, deserialize_with = "aube_util::string_or_seq")]
cpu: Vec<String>,
#[serde(default, deserialize_with = "aube_util::string_or_seq")]
libc: Vec<String>,
#[serde(default, deserialize_with = "aube_manifest::engines_tolerant")]
engines: BTreeMap<String, String>,
#[serde(default)]
bin: BTreeMap<String, String>,
#[serde(default)]
license: Option<String>,
#[serde(default)]
funding: Option<RawNpmFunding>,
}
#[derive(Clone)]
struct InstallPathInfo {
name: String,
dep_path: String,
}
#[derive(Debug, Clone, Default)]
struct RawNpmFunding {
url: Option<String>,
}
impl<'de> Deserialize<'de> for RawNpmFunding {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{MapAccess, SeqAccess, Visitor};
use std::fmt;
struct FundingVisitor;
impl<'de> Visitor<'de> for FundingVisitor {
type Value = RawNpmFunding;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a funding URL string, a {url: ...} object, or an array of either")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(RawNpmFunding {
url: Some(v.to_owned()),
})
}
fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(RawNpmFunding { url: Some(v) })
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
let mut url: Option<String> = None;
while let Some(key) = map.next_key::<String>()? {
if key == "url" {
url = map.next_value::<Option<String>>()?;
} else {
let _ = map.next_value::<serde::de::IgnoredAny>()?;
}
}
Ok(RawNpmFunding { url })
}
fn visit_seq<S>(self, mut seq: S) -> Result<Self::Value, S::Error>
where
S: SeqAccess<'de>,
{
let mut chosen: Option<String> = None;
while let Some(item) = seq.next_element::<RawNpmFunding>()? {
if chosen.is_none() {
chosen = item.url;
}
}
Ok(RawNpmFunding { url: chosen })
}
}
deserializer.deserialize_any(FundingVisitor)
}
}
#[derive(Debug, Clone, Default, Deserialize)]
struct RawNpmPeerDepMeta {
#[serde(default)]
optional: bool,
}
pub fn parse(path: &Path) -> Result<LockfileGraph, Error> {
let content = crate::read_lockfile(path)?;
let raw: RawNpmLockfile = crate::parse_json(path, content)?;
if raw.lockfile_version < 2 {
return Err(Error::parse(
path,
format!(
"package-lock.json lockfileVersion {} is not supported (need v2 or v3)",
raw.lockfile_version
),
));
}
let mut graph = LockfileGraph {
importers: BTreeMap::new(),
packages: BTreeMap::new(),
..Default::default()
};
let link_targets: BTreeSet<String> = raw
.packages
.values()
.filter_map(|entry| entry.link.then(|| entry.resolved.clone()).flatten())
.collect();
let mut install_path_info: BTreeMap<String, InstallPathInfo> = BTreeMap::new();
for (install_path, entry) in &raw.packages {
if install_path.is_empty() {
continue; }
if link_targets.contains(install_path) {
continue;
}
let install_name = package_name_from_install_path(install_path)
.or_else(|| entry.name.clone())
.ok_or_else(|| {
Error::parse(
path,
format!("could not determine package name for '{install_path}'"),
)
})?;
let alias_of = entry
.name
.as_ref()
.filter(|real| real.as_str() != install_name.as_str())
.cloned();
let (package_entry, version, dep_path, local_source) = if entry.link {
let target = entry.resolved.as_ref().ok_or_else(|| {
Error::parse(
path,
format!("linked package '{install_name}' has no resolved target"),
)
})?;
let target_entry = raw.packages.get(target).ok_or_else(|| {
Error::parse(
path,
format!("linked package '{install_name}' points to missing target '{target}'"),
)
})?;
let version = target_entry.version.clone().ok_or_else(|| {
Error::parse(
path,
format!("linked package '{install_name}' target '{target}' has no version"),
)
})?;
let local = LocalSource::Link(PathBuf::from(target));
(
target_entry,
version,
local.dep_path(&install_name),
Some(local),
)
} else {
let version = entry.version.clone().ok_or_else(|| {
Error::parse(path, format!("package '{install_name}' has no version"))
})?;
let local_source = entry
.resolved
.as_deref()
.and_then(local_git_source_from_resolved);
let dep_path = local_source.as_ref().map_or_else(
|| format!("{install_name}@{version}"),
|l| l.dep_path(&install_name),
);
(entry, version.clone(), dep_path, local_source)
};
install_path_info.insert(
install_path.clone(),
InstallPathInfo {
name: install_name.clone(),
dep_path: dep_path.clone(),
},
);
if graph.packages.contains_key(&dep_path) {
continue;
}
let mut deps: BTreeMap<String, String> = BTreeMap::new();
for dep_name in package_entry
.dependencies
.keys()
.chain(package_entry.optional_dependencies.keys())
{
deps.insert(dep_name.clone(), String::new());
}
let mut declared: BTreeMap<String, String> = BTreeMap::new();
for (k, v) in package_entry
.dependencies
.iter()
.chain(package_entry.optional_dependencies.iter())
{
declared.insert(k.clone(), v.clone());
}
let tarball_url = package_entry
.resolved
.as_ref()
.filter(|_| local_source.is_none())
.filter(|u| u.starts_with("http://") || u.starts_with("https://"))
.cloned();
let peer_dependencies = package_entry.peer_dependencies.clone();
let peer_dependencies_meta: BTreeMap<String, crate::PeerDepMeta> = package_entry
.peer_dependencies_meta
.iter()
.map(|(k, v)| {
(
k.clone(),
crate::PeerDepMeta {
optional: v.optional,
},
)
})
.collect();
graph.packages.insert(
dep_path.clone(),
LockedPackage {
name: install_name,
version,
integrity: package_entry.integrity.clone(),
dependencies: deps,
peer_dependencies,
peer_dependencies_meta,
dep_path,
local_source,
os: package_entry.os.iter().cloned().collect(),
cpu: package_entry.cpu.iter().cloned().collect(),
libc: package_entry.libc.iter().cloned().collect(),
alias_of,
tarball_url,
declared_dependencies: declared,
engines: package_entry.engines.clone(),
bin: package_entry.bin.clone(),
license: package_entry.license.clone(),
funding_url: package_entry.funding.as_ref().and_then(|f| f.url.clone()),
..Default::default()
},
);
}
type ResolvedDepMap = BTreeMap<String, String>;
let mut resolved_by_dep_path: BTreeMap<String, (ResolvedDepMap, ResolvedDepMap)> =
BTreeMap::new();
for (install_path, entry) in &raw.packages {
if install_path.is_empty() {
continue;
}
if link_targets.contains(install_path) {
continue;
}
let Some(info) = install_path_info.get(install_path) else {
continue;
};
let package_entry = if entry.link {
let Some(target) = entry.resolved.as_ref() else {
continue;
};
let Some(target_entry) = raw.packages.get(target) else {
unreachable!("first pass validates that linked package target '{target}' exists");
};
target_entry
} else {
entry
};
let dep_path = info.dep_path.clone();
let lookup_path = if entry.link {
entry.resolved.as_deref().unwrap_or(install_path.as_str())
} else {
install_path.as_str()
};
if resolved_by_dep_path.contains_key(&dep_path) {
continue;
}
let mut resolved: BTreeMap<String, String> = BTreeMap::new();
let mut resolved_optional: BTreeMap<String, String> = BTreeMap::new();
for (dep_name, is_optional) in package_entry
.dependencies
.keys()
.map(|name| (name, false))
.chain(
package_entry
.optional_dependencies
.keys()
.map(|name| (name, true)),
)
{
if let Some(target_install_path) =
resolve_nested(lookup_path, dep_name, &install_path_info)
&& let Some(target_info) = install_path_info.get(&target_install_path)
{
let tail = dep_path_tail(&target_info.name, &target_info.dep_path).to_string();
resolved.insert(dep_name.clone(), tail.clone());
if is_optional {
resolved_optional.insert(dep_name.clone(), tail);
}
}
}
resolved_by_dep_path.insert(dep_path, (resolved, resolved_optional));
}
for (dep_path, (deps, optional_deps)) in resolved_by_dep_path {
if let Some(pkg) = graph.packages.get_mut(&dep_path) {
pkg.dependencies = deps;
pkg.optional_dependencies = optional_deps;
}
}
let root = raw.packages.get("").cloned().unwrap_or_default();
let mut direct: Vec<DirectDep> = Vec::new();
let push_direct = |dep_name: &str, dep_type: DepType, direct: &mut Vec<DirectDep>| {
let root_path = format!("node_modules/{dep_name}");
if let Some(info) = install_path_info.get(&root_path) {
direct.push(DirectDep {
name: info.name.clone(),
dep_path: info.dep_path.clone(),
dep_type,
specifier: None,
});
}
};
for dep_name in root.dependencies.keys() {
push_direct(dep_name, DepType::Production, &mut direct);
}
for dep_name in root.dev_dependencies.keys() {
push_direct(dep_name, DepType::Dev, &mut direct);
}
for dep_name in root.optional_dependencies.keys() {
push_direct(dep_name, DepType::Optional, &mut direct);
}
let already_added: BTreeSet<&str> = direct.iter().map(|d| d.name.as_str()).collect();
let mut workspace_links: Vec<DirectDep> = Vec::new();
for (install_path, raw_entry) in &raw.packages {
if !raw_entry.link {
continue;
}
let Some(rest) = install_path.strip_prefix("node_modules/") else {
continue;
};
if rest.contains("/node_modules/") {
continue;
}
let segments = rest.split('/').count();
let expected = if rest.starts_with('@') { 2 } else { 1 };
if segments != expected {
continue;
}
let Some(info) = install_path_info.get(install_path) else {
continue;
};
if already_added.contains(info.name.as_str()) {
continue;
}
workspace_links.push(DirectDep {
name: info.name.clone(),
dep_path: info.dep_path.clone(),
dep_type: DepType::Production,
specifier: None,
});
}
workspace_links.sort_by(|a, b| a.name.cmp(&b.name));
direct.extend(workspace_links);
graph.importers.insert(".".to_string(), direct);
for target in &link_targets {
if target.is_empty() {
continue;
}
let Some(package_entry) = raw.packages.get(target) else {
continue;
};
let mut direct = Vec::new();
for (dep_name, specifier, dep_type) in package_entry
.dependencies
.iter()
.map(|(name, spec)| (name, spec, DepType::Production))
.chain(
package_entry
.dev_dependencies
.iter()
.map(|(name, spec)| (name, spec, DepType::Dev)),
)
.chain(
package_entry
.optional_dependencies
.iter()
.map(|(name, spec)| (name, spec, DepType::Optional)),
)
{
if let Some(target_install_path) = resolve_nested(target, dep_name, &install_path_info)
&& let Some(info) = install_path_info.get(&target_install_path)
{
direct.push(DirectDep {
name: info.name.clone(),
dep_path: info.dep_path.clone(),
dep_type,
specifier: Some(specifier.clone()),
});
}
}
graph.importers.insert(target.clone(), direct);
}
Ok(graph)
}
pub(crate) fn dep_path_tail<'a>(name: &str, dep_path: &'a str) -> &'a str {
dep_path
.strip_prefix(name)
.and_then(|rest| rest.strip_prefix('@'))
.unwrap_or_else(|| {
debug_assert!(
false,
"dep_path '{dep_path}' does not start with name '{name}'"
);
dep_path
})
}
fn local_git_source_from_resolved(resolved: &str) -> Option<LocalSource> {
let (url, committish, subpath) = crate::parse_git_spec(resolved)?;
let resolved = committish.clone()?;
Some(LocalSource::Git(GitSource {
url,
committish,
resolved,
subpath,
}))
}
fn npm_resolved_field(pkg: &LockedPackage) -> Option<String> {
pkg.tarball_url.clone().or_else(|| match &pkg.local_source {
Some(LocalSource::Git(git)) => {
let url = if git.url.starts_with("git://") || git.url.starts_with("git+") {
git.url.clone()
} else {
format!("git+{}", git.url)
};
match &git.subpath {
Some(subpath) => Some(format!("{url}#{}&path:/{subpath}", git.resolved)),
None => Some(format!("{url}#{}", git.resolved)),
}
}
_ => None,
})
}
fn resolve_nested(
pkg_install_path: &str,
dep_name: &str,
install_paths: &BTreeMap<String, InstallPathInfo>,
) -> Option<String> {
let mut base = pkg_install_path.to_string();
loop {
let candidate = if base.is_empty() {
format!("node_modules/{dep_name}")
} else {
format!("{base}/node_modules/{dep_name}")
};
if install_paths.contains_key(&candidate) {
return Some(candidate);
}
if base.is_empty() {
return None;
}
if let Some(idx) = base.rfind("/node_modules/") {
base.truncate(idx);
} else {
base.clear();
}
}
}
fn package_name_from_install_path(install_path: &str) -> Option<String> {
let nm_idx = install_path.rfind("node_modules/")?;
let tail = &install_path[nm_idx + "node_modules/".len()..];
if tail.is_empty() {
return None;
}
if let Some(rest) = tail.strip_prefix('@') {
let slash = rest.find('/')?;
let scoped_end = slash + 1;
let name_end = rest[scoped_end..]
.find('/')
.map(|i| scoped_end + i)
.unwrap_or(rest.len());
return Some(format!("@{}", &rest[..name_end]));
}
let end = tail.find('/').unwrap_or(tail.len());
Some(tail[..end].to_string())
}
#[derive(Debug, Serialize)]
struct WriteNpmLockfile<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<&'a str>,
#[serde(rename = "lockfileVersion")]
lockfile_version: u32,
requires: bool,
packages: BTreeMap<String, WriteNpmPackage<'a>>,
}
#[derive(Debug, Serialize, Default)]
#[serde(rename_all = "camelCase")]
struct WriteNpmPackage<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
resolved: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
integrity: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
license: Option<&'a str>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
dependencies: BTreeMap<&'a str, &'a str>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
dev_dependencies: BTreeMap<&'a str, &'a str>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
optional_dependencies: BTreeMap<&'a str, &'a str>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
peer_dependencies: BTreeMap<&'a str, &'a str>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
peer_dependencies_meta: BTreeMap<&'a str, WriteNpmPeerDepMeta>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
bin: BTreeMap<&'a str, &'a str>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
engines: BTreeMap<&'a str, &'a str>,
#[serde(skip_serializing_if = "Vec::is_empty")]
os: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
cpu: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
libc: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
funding: Option<WriteNpmFunding<'a>>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
link: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
dev: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
optional: bool,
#[serde(rename = "devOptional", skip_serializing_if = "std::ops::Not::not")]
dev_optional: bool,
}
#[derive(Debug, Serialize, Default)]
struct WriteNpmFunding<'a> {
url: &'a str,
}
#[derive(Debug, Serialize, Default)]
struct WriteNpmPeerDepMeta {
#[serde(skip_serializing_if = "std::ops::Not::not")]
optional: bool,
}
pub fn write(
path: &Path,
graph: &LockfileGraph,
manifest: &aube_manifest::PackageJson,
) -> Result<(), Error> {
let mut canonical = crate::build_canonical_map(graph);
for pkg in graph
.packages
.values()
.filter(|pkg| matches!(pkg.local_source, Some(LocalSource::Git(_))))
{
canonical
.entry(canonical_key_from_dep_path(&pkg.dep_path))
.or_insert(pkg);
}
let roots = graph.importers.get(".").cloned().unwrap_or_default();
let all_roots: Vec<DirectDep> = graph
.importers
.values()
.flat_map(|deps| deps.iter().cloned())
.collect();
let prod_reach = reachable_from(&canonical, &all_roots, DepType::Production);
let dev_reach = reachable_from(&canonical, &all_roots, DepType::Dev);
let opt_reach = reachable_from(&canonical, &all_roots, DepType::Optional);
let root_tree_roots = non_link_roots(graph, &roots);
let tree = build_hoist_tree(&canonical, &root_tree_roots);
let mut placed: BTreeMap<String, String> = tree
.into_iter()
.map(|(segs, key)| (segments_to_install_path(&segs), key))
.collect();
let root_key = "";
let mut packages: BTreeMap<String, WriteNpmPackage> = BTreeMap::new();
packages.insert(
root_key.to_string(),
WriteNpmPackage {
name: manifest.name.as_deref(),
version: manifest.version.as_deref(),
dependencies: borrow_map(&manifest.dependencies),
dev_dependencies: borrow_map(&manifest.dev_dependencies),
optional_dependencies: borrow_map(&manifest.optional_dependencies),
peer_dependencies: borrow_map(&manifest.peer_dependencies),
..Default::default()
},
);
for (importer_path, importer_roots) in graph.importers.iter().filter(|(path, _)| *path != ".") {
let Some(workspace_pkg) = workspace_package_for_importer(graph, importer_path) else {
continue;
};
let (dependencies, dev_dependencies, optional_dependencies) =
dep_sections_from_direct_deps(importer_roots);
packages.insert(
importer_path.clone(),
WriteNpmPackage {
name: Some(workspace_pkg.name.as_str()),
version: Some(workspace_pkg.version.as_str()),
dependencies,
dev_dependencies,
optional_dependencies,
peer_dependencies: workspace_pkg
.peer_dependencies
.iter()
.map(|(n, v)| (n.as_str(), v.as_str()))
.collect(),
..Default::default()
},
);
packages.insert(
format!("node_modules/{}", workspace_pkg.name),
WriteNpmPackage {
resolved: Some(importer_path.clone()),
link: true,
..Default::default()
},
);
let workspace_tree_roots = non_link_roots(graph, importer_roots);
let workspace_tree = build_hoist_tree(&canonical, &workspace_tree_roots);
let redundant_tops: BTreeSet<String> = workspace_tree
.iter()
.filter(|(segs, key)| {
segs.len() == 1
&& placed
.get(&format!("node_modules/{}", segs[0]))
.is_some_and(|root_key| root_key == *key)
})
.map(|(segs, _)| segs[0].clone())
.collect();
for (segs, canonical_key) in workspace_tree {
if redundant_tops.contains(&segs[0]) {
continue;
}
let install_path = format!("{importer_path}/{}", segments_to_install_path(&segs));
placed.entry(install_path).or_insert(canonical_key);
}
}
for (install_path, canonical_key) in &placed {
let Some(pkg) = canonical.get(canonical_key).copied() else {
continue;
};
let optional_deps: BTreeMap<&str, &str> = pkg
.optional_dependencies
.iter()
.filter(|(n, value)| canonical.contains_key(&child_canonical_key(n, value)))
.map(|(n, value)| {
let rendered = pkg
.declared_dependencies
.get(n)
.map(String::as_str)
.unwrap_or_else(|| dep_value_as_version(n, value));
(n.as_str(), rendered)
})
.collect();
let deps: BTreeMap<&str, &str> = pkg
.dependencies
.iter()
.filter(|(n, value)| {
!pkg.optional_dependencies.contains_key(*n)
&& canonical.contains_key(&child_canonical_key(n, value))
})
.map(|(n, value)| {
let rendered = pkg
.declared_dependencies
.get(n)
.map(String::as_str)
.unwrap_or_else(|| dep_value_as_version(n, value));
(n.as_str(), rendered)
})
.collect();
let is_prod = prod_reach.contains(canonical_key);
let is_dev = !is_prod && dev_reach.contains(canonical_key);
let is_opt = !is_prod && opt_reach.contains(canonical_key);
let dev_optional = is_dev && is_opt;
let dev = is_dev && !dev_optional;
let optional = is_opt && !dev_optional;
let alias_name = pkg.alias_of.as_deref();
let resolved = npm_resolved_field(pkg);
let peer_deps: BTreeMap<&str, &str> = pkg
.peer_dependencies
.iter()
.map(|(n, v)| (n.as_str(), v.as_str()))
.collect();
let peer_deps_meta: BTreeMap<&str, WriteNpmPeerDepMeta> = pkg
.peer_dependencies_meta
.iter()
.map(|(n, m)| {
(
n.as_str(),
WriteNpmPeerDepMeta {
optional: m.optional,
},
)
})
.collect();
packages.insert(
install_path.clone(),
WriteNpmPackage {
name: alias_name,
version: Some(pkg.version.as_str()),
resolved,
integrity: pkg.integrity.as_deref(),
license: pkg.license.as_deref(),
dependencies: deps,
optional_dependencies: optional_deps,
peer_dependencies: peer_deps,
peer_dependencies_meta: peer_deps_meta,
bin: pkg
.bin
.iter()
.filter(|(k, _)| !k.is_empty())
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect(),
engines: pkg
.engines
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect(),
os: pkg.os.to_vec(),
cpu: pkg.cpu.to_vec(),
libc: pkg.libc.to_vec(),
funding: pkg
.funding_url
.as_deref()
.map(|url| WriteNpmFunding { url }),
dev,
optional,
dev_optional,
..Default::default()
},
);
}
let doc = WriteNpmLockfile {
name: manifest.name.as_deref(),
version: manifest.version.as_deref(),
lockfile_version: 3,
requires: true,
packages,
};
let mut body =
serde_json::to_string_pretty(&doc).map_err(|e| Error::parse(path, e.to_string()))?;
body.push('\n');
crate::atomic_write_lockfile(path, body.as_bytes())?;
Ok(())
}
fn workspace_package_for_importer<'a>(
graph: &'a LockfileGraph,
importer_path: &str,
) -> Option<&'a LockedPackage> {
graph.packages.values().find(|pkg| {
matches!(
&pkg.local_source,
Some(LocalSource::Link(path)) if path == Path::new(importer_path)
)
})
}
fn non_link_roots(graph: &LockfileGraph, roots: &[DirectDep]) -> Vec<DirectDep> {
roots
.iter()
.filter(|dep| {
!graph
.packages
.get(&dep.dep_path)
.is_some_and(|pkg| matches!(pkg.local_source, Some(LocalSource::Link(_))))
})
.cloned()
.collect()
}
type DepSections<'a> = (
BTreeMap<&'a str, &'a str>,
BTreeMap<&'a str, &'a str>,
BTreeMap<&'a str, &'a str>,
);
fn dep_sections_from_direct_deps(deps: &[DirectDep]) -> DepSections<'_> {
let mut dependencies = BTreeMap::new();
let mut dev_dependencies = BTreeMap::new();
let mut optional_dependencies = BTreeMap::new();
for dep in deps {
let rendered = dep.specifier.as_deref().unwrap_or_else(|| {
dep_value_as_version(&dep.name, dep_path_tail(&dep.name, &dep.dep_path))
});
match dep.dep_type {
DepType::Production => {
dependencies.insert(dep.name.as_str(), rendered);
}
DepType::Dev => {
dev_dependencies.insert(dep.name.as_str(), rendered);
}
DepType::Optional => {
optional_dependencies.insert(dep.name.as_str(), rendered);
}
}
}
(dependencies, dev_dependencies, optional_dependencies)
}
pub(crate) fn segments_to_install_path(segs: &[String]) -> String {
if segs.is_empty() {
return String::new();
}
let mut out = String::from("node_modules/");
for (i, s) in segs.iter().enumerate() {
if i > 0 {
out.push_str("/node_modules/");
}
out.push_str(s);
}
out
}
pub(crate) fn build_hoist_tree(
canonical: &BTreeMap<String, &LockedPackage>,
roots: &[DirectDep],
) -> BTreeMap<Vec<String>, String> {
let mut placed: BTreeMap<Vec<String>, String> = BTreeMap::new();
let mut queue: VecDeque<(Vec<String>, String)> = VecDeque::new();
for dep in roots {
let key = canonical_key_from_dep_path(&dep.dep_path);
if !canonical.contains_key(&key) {
continue;
}
let segs = vec![dep.name.clone()];
if placed.insert(segs.clone(), key.clone()).is_none() {
queue.push_back((segs, key));
}
}
while let Some((parent_segs, parent_key)) = queue.pop_front() {
let Some(pkg) = canonical.get(&parent_key).copied() else {
continue;
};
let mut child_entries: Vec<(String, String)> = Vec::new();
for (child_name, child_value) in &pkg.dependencies {
let child_key = child_canonical_key(child_name, child_value);
if !canonical.contains_key(&child_key) {
continue;
}
child_entries.push((child_name.clone(), child_key));
}
for (child_name, child_key) in child_entries {
match ancestor_resolution(&parent_segs, &child_name, &child_key, &placed) {
AncestorHit::Match => continue,
AncestorHit::Shadowed => {
let mut nested = parent_segs.clone();
nested.push(child_name.clone());
if placed.insert(nested.clone(), child_key.clone()).is_none() {
queue.push_back((nested, child_key));
}
}
AncestorHit::Miss => {
let root_slot = vec![child_name.clone()];
if placed
.insert(root_slot.clone(), child_key.clone())
.is_none()
{
queue.push_back((root_slot, child_key));
}
}
}
}
}
placed
}
enum AncestorHit {
Match,
Shadowed,
Miss,
}
fn ancestor_resolution(
parent_segs: &[String],
child_name: &str,
child_key: &str,
placed: &BTreeMap<Vec<String>, String>,
) -> AncestorHit {
for i in (0..=parent_segs.len()).rev() {
let mut candidate: Vec<String> = parent_segs[..i].to_vec();
candidate.push(child_name.to_string());
if let Some(existing) = placed.get(&candidate) {
return if existing == child_key {
AncestorHit::Match
} else {
AncestorHit::Shadowed
};
}
}
AncestorHit::Miss
}
fn reachable_from(
canonical: &BTreeMap<String, &LockedPackage>,
roots: &[DirectDep],
dep_type: DepType,
) -> BTreeSet<String> {
let mut out: BTreeSet<String> = BTreeSet::new();
let mut queue: VecDeque<String> = VecDeque::new();
for dep in roots {
if dep.dep_type != dep_type {
continue;
}
let key = canonical_key_from_dep_path(&dep.dep_path);
if canonical.contains_key(&key) && out.insert(key.clone()) {
queue.push_back(key);
}
}
while let Some(key) = queue.pop_front() {
let Some(pkg) = canonical.get(&key).copied() else {
continue;
};
for (child_name, child_value) in &pkg.dependencies {
let child_key = child_canonical_key(child_name, child_value);
if canonical.contains_key(&child_key) && out.insert(child_key.clone()) {
queue.push_back(child_key);
}
}
}
out
}
fn version_from_tail(tail: &str) -> &str {
tail.split_once('(').map(|(v, _)| v).unwrap_or(tail)
}
pub(crate) fn child_canonical_key(child_name: &str, value: &str) -> String {
let no_peer = version_from_tail(value);
let prefix = format!("{child_name}@");
if no_peer.starts_with(&prefix) {
no_peer.to_string()
} else {
format!("{prefix}{no_peer}")
}
}
pub(crate) fn dep_value_as_version<'a>(child_name: &str, value: &'a str) -> &'a str {
let no_peer = version_from_tail(value);
let prefix = format!("{child_name}@");
if let Some(rest) = no_peer.strip_prefix(&prefix) {
rest
} else {
no_peer
}
}
pub(crate) fn canonical_key_from_dep_path(dep_path: &str) -> String {
let trimmed = version_from_tail(dep_path);
let (name, version) = match trimmed.rfind('@') {
Some(0) | None => return trimmed.to_string(),
Some(idx) => (&trimmed[..idx], &trimmed[idx + 1..]),
};
format!("{name}@{version}")
}
fn borrow_map(m: &BTreeMap<String, String>) -> BTreeMap<&str, &str> {
m.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_package_name_from_install_path() {
assert_eq!(
package_name_from_install_path("node_modules/foo"),
Some("foo".to_string())
);
assert_eq!(
package_name_from_install_path("node_modules/@scope/pkg"),
Some("@scope/pkg".to_string())
);
assert_eq!(
package_name_from_install_path("node_modules/foo/node_modules/bar"),
Some("bar".to_string())
);
assert_eq!(
package_name_from_install_path("node_modules/foo/node_modules/@scope/pkg"),
Some("@scope/pkg".to_string())
);
}
#[test]
fn test_parse_simple() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test",
"version": "1.0.0",
"dependencies": { "foo": "^1.0.0" },
"devDependencies": { "bar": "^2.0.0" }
},
"node_modules/foo": {
"version": "1.2.3",
"integrity": "sha512-aaa",
"dependencies": { "nested": "^3.0.0" }
},
"node_modules/nested": {
"version": "3.1.0",
"integrity": "sha512-bbb"
},
"node_modules/bar": {
"version": "2.5.0",
"integrity": "sha512-ccc",
"dev": true
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
assert_eq!(graph.packages.len(), 3);
assert!(graph.packages.contains_key("foo@1.2.3"));
assert!(graph.packages.contains_key("nested@3.1.0"));
assert!(graph.packages.contains_key("bar@2.5.0"));
let foo = &graph.packages["foo@1.2.3"];
assert_eq!(foo.integrity.as_deref(), Some("sha512-aaa"));
assert_eq!(
foo.dependencies.get("nested").map(String::as_str),
Some("3.1.0")
);
let root = graph.importers.get(".").unwrap();
assert_eq!(root.len(), 2);
assert!(
root.iter()
.any(|d| d.name == "foo" && d.dep_type == DepType::Production)
);
assert!(
root.iter()
.any(|d| d.name == "bar" && d.dep_type == DepType::Dev)
);
}
#[test]
fn test_parse_git_resolved_package() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let sha = "abcdef1234567890abcdef1234567890abcdef12";
let content = format!(
r#"{{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 2,
"packages": {{
"": {{
"name": "test",
"version": "1.0.0",
"dependencies": {{ "git-only": "github:owner/repo#{sha}" }}
}},
"node_modules/git-only": {{
"version": "1.2.3",
"resolved": "git+ssh://git@github.com/owner/repo.git#{sha}",
"integrity": "sha512-aaa"
}}
}}
}}"#
);
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let root = &graph.importers["."];
assert_eq!(root.len(), 1);
assert_eq!(root[0].name, "git-only");
assert!(!graph.packages.contains_key("git-only@1.2.3"));
let pkg = &graph.packages[&root[0].dep_path];
assert_eq!(pkg.name, "git-only");
assert_eq!(pkg.version, "1.2.3");
assert_eq!(pkg.integrity.as_deref(), Some("sha512-aaa"));
assert!(pkg.tarball_url.is_none());
let Some(LocalSource::Git(git)) = &pkg.local_source else {
panic!("expected git local source, got {:?}", pkg.local_source);
};
assert_eq!(git.url, "ssh://git@github.com/owner/repo.git");
assert_eq!(git.committish.as_deref(), Some(sha));
assert_eq!(git.resolved, sha);
}
#[test]
fn test_unpinned_git_resolved_url_is_not_locked_git_source() {
assert!(local_git_source_from_resolved("git+https://github.com/owner/repo.git").is_none());
}
#[test]
fn test_write_preserves_git_resolved_url() {
let sha = "abcdef1234567890abcdef1234567890abcdef12";
let mut graph = LockfileGraph::default();
let local = LocalSource::Git(GitSource {
url: "ssh://git@github.com/owner/repo.git".to_string(),
committish: Some(sha.to_string()),
resolved: sha.to_string(),
subpath: None,
});
let dep_path = local.dep_path("git-only");
graph.packages.insert(
dep_path.clone(),
LockedPackage {
name: "git-only".to_string(),
version: "1.2.3".to_string(),
dep_path: dep_path.clone(),
local_source: Some(local),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![DirectDep {
name: "git-only".to_string(),
dep_path,
dep_type: DepType::Production,
specifier: Some(format!("github:owner/repo#{sha}")),
}],
);
let manifest = aube_manifest::PackageJson {
name: Some("test".to_string()),
version: Some("1.0.0".to_string()),
dependencies: [("git-only".to_string(), format!("github:owner/repo#{sha}"))]
.into_iter()
.collect(),
..Default::default()
};
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let body = std::fs::read_to_string(out.path()).unwrap();
assert!(
body.contains(&format!(
"\"resolved\": \"git+ssh://git@github.com/owner/repo.git#{sha}\""
)),
"expected git resolved URL emitted; got:\n{body}"
);
let reparsed = parse(out.path()).unwrap();
let pkg = &reparsed.packages[&graph.importers["."][0].dep_path];
assert!(matches!(pkg.local_source, Some(LocalSource::Git(_))));
}
#[test]
fn test_write_skips_non_git_local_sources() {
let local = LocalSource::Directory(PathBuf::from("vendor/local-dir"));
let dep_path = local.dep_path("local-dir");
let mut graph = LockfileGraph::default();
graph.packages.insert(
dep_path.clone(),
LockedPackage {
name: "local-dir".to_string(),
version: "1.0.0".to_string(),
dep_path: dep_path.clone(),
local_source: Some(local),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![DirectDep {
name: "local-dir".to_string(),
dep_path,
dep_type: DepType::Production,
specifier: Some("file:vendor/local-dir".to_string()),
}],
);
let manifest = aube_manifest::PackageJson {
name: Some("test".to_string()),
version: Some("1.0.0".to_string()),
dependencies: [("local-dir".to_string(), "file:vendor/local-dir".to_string())]
.into_iter()
.collect(),
..Default::default()
};
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let body = std::fs::read_to_string(out.path()).unwrap();
assert!(!body.contains("\"node_modules/local-dir\""));
}
#[test]
fn test_parse_scoped_package() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"lockfileVersion": 3,
"packages": {
"": {
"dependencies": { "@scope/pkg": "^1.0.0" }
},
"node_modules/@scope/pkg": {
"version": "1.0.0",
"integrity": "sha512-zzz"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
assert!(graph.packages.contains_key("@scope/pkg@1.0.0"));
let root = graph.importers.get(".").unwrap();
assert_eq!(root[0].name, "@scope/pkg");
assert_eq!(root[0].dep_path, "@scope/pkg@1.0.0");
}
#[test]
fn test_parse_multi_version_nested() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"lockfileVersion": 3,
"packages": {
"": {
"dependencies": { "foo": "^1.0.0", "bar": "^2.0.0" }
},
"node_modules/bar": {
"version": "2.0.0",
"integrity": "sha512-top-bar"
},
"node_modules/foo": {
"version": "1.0.0",
"integrity": "sha512-foo",
"dependencies": { "bar": "^1.0.0" }
},
"node_modules/foo/node_modules/bar": {
"version": "1.0.0",
"integrity": "sha512-nested-bar"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
assert!(graph.packages.contains_key("bar@2.0.0"));
assert!(graph.packages.contains_key("bar@1.0.0"));
assert!(graph.packages.contains_key("foo@1.0.0"));
let foo = &graph.packages["foo@1.0.0"];
assert_eq!(
foo.dependencies.get("bar").map(String::as_str),
Some("1.0.0")
);
let root = graph.importers.get(".").unwrap();
let root_bar = root.iter().find(|d| d.name == "bar").unwrap();
assert_eq!(root_bar.dep_path, "bar@2.0.0");
}
#[test]
fn test_write_dev_and_optional_reachable_uses_dev_optional() {
let mut graph = LockfileGraph::default();
let mk = |name: &str| LockedPackage {
name: name.to_string(),
version: "1.0.0".to_string(),
integrity: Some(format!("sha512-{name}")),
dep_path: format!("{name}@1.0.0"),
dependencies: [("shared".to_string(), "1.0.0".to_string())]
.into_iter()
.collect(),
..Default::default()
};
graph
.packages
.insert("dev-root@1.0.0".to_string(), mk("dev-root"));
graph
.packages
.insert("opt-root@1.0.0".to_string(), mk("opt-root"));
graph.packages.insert(
"shared@1.0.0".to_string(),
LockedPackage {
name: "shared".to_string(),
version: "1.0.0".to_string(),
integrity: Some("sha512-shared".to_string()),
dep_path: "shared@1.0.0".to_string(),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![
DirectDep {
name: "dev-root".to_string(),
dep_path: "dev-root@1.0.0".to_string(),
dep_type: DepType::Dev,
specifier: None,
},
DirectDep {
name: "opt-root".to_string(),
dep_path: "opt-root@1.0.0".to_string(),
dep_type: DepType::Optional,
specifier: None,
},
],
);
let manifest = aube_manifest::PackageJson {
name: Some("test".to_string()),
version: Some("1.0.0".to_string()),
dev_dependencies: [("dev-root".to_string(), "^1.0.0".to_string())]
.into_iter()
.collect(),
optional_dependencies: [("opt-root".to_string(), "^1.0.0".to_string())]
.into_iter()
.collect(),
..Default::default()
};
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(out.path()).unwrap()).unwrap();
let shared = &json["packages"]["node_modules/shared"];
assert_eq!(shared["devOptional"], true, "expected devOptional flag");
assert!(
shared.get("dev").is_none(),
"must not emit dev: true alongside devOptional",
);
assert!(
shared.get("optional").is_none(),
"must not emit optional: true alongside devOptional",
);
assert_eq!(json["packages"]["node_modules/dev-root"]["dev"], true);
assert_eq!(json["packages"]["node_modules/opt-root"]["optional"], true);
}
#[test]
fn test_write_filters_missing_canonical_deps() {
let mut graph = LockfileGraph::default();
graph.packages.insert(
"foo@1.0.0".to_string(),
LockedPackage {
name: "foo".to_string(),
version: "1.0.0".to_string(),
integrity: Some("sha512-foo".to_string()),
dep_path: "foo@1.0.0".to_string(),
dependencies: [("ghost".to_string(), "1.0.0".to_string())]
.into_iter()
.collect(),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![DirectDep {
name: "foo".to_string(),
dep_path: "foo@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: None,
}],
);
let manifest = test_manifest();
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(out.path()).unwrap()).unwrap();
let foo_entry = &json["packages"]["node_modules/foo"];
assert!(
foo_entry
.get("dependencies")
.and_then(|d| d.get("ghost"))
.is_none(),
"writer emitted a ghost dep that has no packages entry: {foo_entry}",
);
assert!(
json["packages"].get("node_modules/ghost").is_none(),
"writer hallucinated a ghost entry",
);
}
#[test]
fn test_nested_shadow_forces_nested_placement() {
let mut graph = LockfileGraph::default();
let mk = |name: &str, version: &str, deps: &[(&str, &str)]| LockedPackage {
name: name.to_string(),
version: version.to_string(),
integrity: Some(format!("sha512-{name}-{version}")),
dep_path: format!("{name}@{version}"),
dependencies: deps
.iter()
.map(|(n, v)| (n.to_string(), (*v).to_string()))
.collect(),
..Default::default()
};
graph.packages.insert(
"foo@1.0.0".to_string(),
mk(
"foo",
"1.0.0",
&[
("bar", "1.0.0"),
("baz", "1.0.0"),
],
),
);
graph.packages.insert(
"baz@1.0.0".to_string(),
mk("baz", "1.0.0", &[("bar", "2.0.0")]),
);
graph
.packages
.insert("bar@1.0.0".to_string(), mk("bar", "1.0.0", &[]));
graph
.packages
.insert("bar@2.0.0".to_string(), mk("bar", "2.0.0", &[]));
graph.importers.insert(
".".to_string(),
vec![
DirectDep {
name: "foo".to_string(),
dep_path: "foo@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: None,
},
DirectDep {
name: "bar".to_string(),
dep_path: "bar@2.0.0".to_string(),
dep_type: DepType::Production,
specifier: None,
},
],
);
let manifest = test_manifest();
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let reparsed = parse(out.path()).unwrap();
let baz = &reparsed.packages["baz@1.0.0"];
assert_eq!(
baz.dependencies.get("bar").map(String::as_str),
Some("2.0.0"),
"baz's bar dep was shadowed by foo/bar@1.0.0 — shadow-nest fix regressed",
);
}
#[test]
fn test_parse_npm_preserves_platform_optional_metadata() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "platform-optional-root",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "platform-optional-root",
"version": "1.0.0",
"dependencies": { "host": "file:host" }
},
"node_modules/host": {
"resolved": "host",
"link": true
},
"host": {
"name": "host",
"version": "1.0.0",
"optionalDependencies": { "native-win": "1.0.0" }
},
"node_modules/native-win": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/native-win/-/native-win-1.0.0.tgz",
"integrity": "sha512-native",
"optional": true,
"os": ["win32"],
"cpu": ["x64"],
"libc": ["glibc"]
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let host_dep_path = &graph.importers["."][0].dep_path;
let host = &graph.packages[host_dep_path];
assert_eq!(
host.dependencies.get("native-win").map(String::as_str),
Some("1.0.0")
);
assert_eq!(
host.optional_dependencies
.get("native-win")
.map(String::as_str),
Some("1.0.0")
);
let native = &graph.packages["native-win@1.0.0"];
assert_eq!(
native.os.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["win32"]
);
assert_eq!(
native.cpu.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["x64"]
);
assert_eq!(
native.libc.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["glibc"]
);
}
#[test]
fn parse_npm_package_platform_fields_accept_scalar_strings() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "scalar-platform-root",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "scalar-platform-root",
"version": "1.0.0",
"dependencies": { "sass-embedded-linux-arm": "1.99.0" }
},
"node_modules/sass-embedded-linux-arm": {
"version": "1.99.0",
"resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.99.0.tgz",
"integrity": "sha512-native",
"cpu": "arm",
"os": "linux",
"libc": "glibc"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let pkg = &graph.packages["sass-embedded-linux-arm@1.99.0"];
assert_eq!(
pkg.os.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["linux"]
);
assert_eq!(
pkg.cpu.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["arm"]
);
assert_eq!(
pkg.libc.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["glibc"]
);
}
#[test]
fn test_write_npm_preserves_platform_optional_metadata() {
let mut graph = LockfileGraph::default();
graph.packages.insert(
"host@1.0.0".to_string(),
LockedPackage {
name: "host".to_string(),
version: "1.0.0".to_string(),
integrity: Some("sha512-host".to_string()),
dep_path: "host@1.0.0".to_string(),
dependencies: [("native-win".to_string(), "1.0.0".to_string())]
.into_iter()
.collect(),
optional_dependencies: [("native-win".to_string(), "1.0.0".to_string())]
.into_iter()
.collect(),
declared_dependencies: [("native-win".to_string(), "1.0.0".to_string())]
.into_iter()
.collect(),
..Default::default()
},
);
graph.packages.insert(
"native-win@1.0.0".to_string(),
LockedPackage {
name: "native-win".to_string(),
version: "1.0.0".to_string(),
integrity: Some("sha512-native".to_string()),
dep_path: "native-win@1.0.0".to_string(),
os: vec!["win32".to_string()].into(),
cpu: vec!["x64".to_string()].into(),
libc: vec!["glibc".to_string()].into(),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![DirectDep {
name: "host".to_string(),
dep_path: "host@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: Some("1.0.0".to_string()),
}],
);
let manifest = aube_manifest::PackageJson {
name: Some("platform-optional-root".to_string()),
version: Some("1.0.0".to_string()),
dependencies: [("host".to_string(), "1.0.0".to_string())]
.into_iter()
.collect(),
..Default::default()
};
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(out.path()).unwrap()).unwrap();
let host = &json["packages"]["node_modules/host"];
assert_eq!(host["optionalDependencies"]["native-win"], "1.0.0");
assert!(
host.get("dependencies")
.and_then(|deps| deps.get("native-win"))
.is_none(),
"optional child must not be duplicated as a required dependency: {host}",
);
let native = &json["packages"]["node_modules/native-win"];
assert_eq!(native["os"], serde_json::json!(["win32"]));
assert_eq!(native["cpu"], serde_json::json!(["x64"]));
assert_eq!(native["libc"], serde_json::json!(["glibc"]));
let reparsed = parse(out.path()).unwrap();
let host = &reparsed.packages["host@1.0.0"];
assert_eq!(
host.optional_dependencies
.get("native-win")
.map(String::as_str),
Some("1.0.0")
);
assert_eq!(
host.dependencies.get("native-win").map(String::as_str),
Some("1.0.0")
);
let native = &reparsed.packages["native-win@1.0.0"];
assert_eq!(
native.os.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["win32"]
);
assert_eq!(
native.cpu.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["x64"]
);
assert_eq!(
native.libc.iter().map(String::as_str).collect::<Vec<_>>(),
vec!["glibc"]
);
}
#[test]
fn test_canonical_key_strips_peer_suffix() {
assert_eq!(canonical_key_from_dep_path("foo@1.0.0"), "foo@1.0.0");
assert_eq!(
canonical_key_from_dep_path("styled-components@6.1.0(react@18.2.0)"),
"styled-components@6.1.0"
);
assert_eq!(
canonical_key_from_dep_path("@scope/pkg@2.0.0(peer@1.0.0)"),
"@scope/pkg@2.0.0"
);
}
fn test_manifest() -> aube_manifest::PackageJson {
aube_manifest::PackageJson {
name: Some("test".to_string()),
version: Some("1.0.0".to_string()),
dependencies: [
("foo".to_string(), "^1.0.0".to_string()),
("bar".to_string(), "^2.0.0".to_string()),
]
.into_iter()
.collect(),
..Default::default()
}
}
#[test]
fn test_write_roundtrip_multi_version() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test",
"version": "1.0.0",
"dependencies": { "foo": "^1.0.0", "bar": "^2.0.0" }
},
"node_modules/bar": {
"version": "2.0.0",
"integrity": "sha512-top-bar"
},
"node_modules/foo": {
"version": "1.0.0",
"integrity": "sha512-foo",
"dependencies": { "bar": "^1.0.0" }
},
"node_modules/foo/node_modules/bar": {
"version": "1.0.0",
"integrity": "sha512-nested-bar"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let manifest = test_manifest();
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let reparsed = parse(out.path()).unwrap();
assert!(reparsed.packages.contains_key("bar@1.0.0"));
assert!(reparsed.packages.contains_key("bar@2.0.0"));
assert!(reparsed.packages.contains_key("foo@1.0.0"));
assert_eq!(
reparsed.packages["bar@2.0.0"].integrity.as_deref(),
Some("sha512-top-bar")
);
assert_eq!(
reparsed.packages["bar@1.0.0"].integrity.as_deref(),
Some("sha512-nested-bar")
);
assert_eq!(
reparsed.packages["foo@1.0.0"]
.dependencies
.get("bar")
.map(String::as_str),
Some("1.0.0")
);
}
#[test]
fn test_write_dev_optional_flags() {
let mut graph = LockfileGraph::default();
graph.packages.insert(
"foo@1.0.0".to_string(),
LockedPackage {
name: "foo".to_string(),
version: "1.0.0".to_string(),
integrity: Some("sha512-foo".to_string()),
dep_path: "foo@1.0.0".to_string(),
..Default::default()
},
);
graph.packages.insert(
"devdep@1.0.0".to_string(),
LockedPackage {
name: "devdep".to_string(),
version: "1.0.0".to_string(),
integrity: Some("sha512-dev".to_string()),
dep_path: "devdep@1.0.0".to_string(),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![
DirectDep {
name: "foo".to_string(),
dep_path: "foo@1.0.0".to_string(),
dep_type: DepType::Production,
specifier: None,
},
DirectDep {
name: "devdep".to_string(),
dep_path: "devdep@1.0.0".to_string(),
dep_type: DepType::Dev,
specifier: None,
},
],
);
let manifest = aube_manifest::PackageJson {
name: Some("test".to_string()),
version: Some("1.0.0".to_string()),
dependencies: [("foo".to_string(), "^1.0.0".to_string())]
.into_iter()
.collect(),
dev_dependencies: [("devdep".to_string(), "^1.0.0".to_string())]
.into_iter()
.collect(),
..Default::default()
};
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(out.path()).unwrap()).unwrap();
let packages = &json["packages"];
assert_eq!(packages["node_modules/devdep"]["dev"], true);
assert!(packages["node_modules/foo"].get("dev").is_none());
}
#[test]
fn test_reject_v1() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"lockfileVersion": 1,
"dependencies": {}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let err = parse(tmp.path()).unwrap_err();
assert!(matches!(err, Error::Parse(_, msg) if msg.contains("lockfileVersion 1")));
}
#[test]
fn test_parse_legacy_array_engines() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test",
"version": "1.0.0",
"dependencies": { "ansi-html-community": "0.0.8" }
},
"node_modules/ansi-html-community": {
"version": "0.0.8",
"integrity": "sha512-aaa",
"engines": ["node >= 0.8.0"]
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let pkg = &graph.packages["ansi-html-community@0.0.8"];
assert!(pkg.engines.is_empty());
}
#[test]
fn test_parse_npm_alias_dependency() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test",
"version": "1.0.0",
"dependencies": { "h3-v2": "npm:h3@2.0.1-rc.20" }
},
"node_modules/h3-v2": {
"name": "h3",
"version": "2.0.1-rc.20",
"resolved": "https://registry.npmjs.org/h3/-/h3-2.0.1-rc.20.tgz",
"integrity": "sha512-aliased"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
assert_eq!(graph.packages.len(), 1);
let pkg = graph
.packages
.get("h3-v2@2.0.1-rc.20")
.expect("aliased entry should be keyed by the alias dep_path");
assert_eq!(pkg.name, "h3-v2");
assert_eq!(pkg.version, "2.0.1-rc.20");
assert_eq!(pkg.alias_of.as_deref(), Some("h3"));
assert_eq!(pkg.registry_name(), "h3");
assert_eq!(
pkg.tarball_url.as_deref(),
Some("https://registry.npmjs.org/h3/-/h3-2.0.1-rc.20.tgz")
);
let root = graph.importers.get(".").unwrap();
assert_eq!(root.len(), 1);
assert_eq!(root[0].name, "h3-v2");
assert_eq!(root[0].dep_path, "h3-v2@2.0.1-rc.20");
}
#[test]
fn test_parse_non_alias_preserves_empty_alias_of() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test",
"version": "1.0.0",
"dependencies": { "foo": "^1.0.0" }
},
"node_modules/foo": {
"name": "foo",
"version": "1.2.3",
"integrity": "sha512-foo"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let pkg = &graph.packages["foo@1.2.3"];
assert_eq!(pkg.name, "foo");
assert!(pkg.alias_of.is_none());
assert_eq!(pkg.registry_name(), "foo");
assert!(pkg.tarball_url.is_none());
}
#[test]
fn test_write_roundtrip_npm_alias() {
let mut graph = LockfileGraph::default();
graph.packages.insert(
"h3-v2@2.0.1-rc.20".to_string(),
LockedPackage {
name: "h3-v2".to_string(),
version: "2.0.1-rc.20".to_string(),
integrity: Some("sha512-aliased".to_string()),
dep_path: "h3-v2@2.0.1-rc.20".to_string(),
alias_of: Some("h3".to_string()),
tarball_url: Some("https://registry.npmjs.org/h3/-/h3-2.0.1-rc.20.tgz".to_string()),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![DirectDep {
name: "h3-v2".to_string(),
dep_path: "h3-v2@2.0.1-rc.20".to_string(),
dep_type: DepType::Production,
specifier: Some("npm:h3@2.0.1-rc.20".to_string()),
}],
);
let manifest = test_manifest();
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let body = std::fs::read_to_string(out.path()).unwrap();
assert!(
body.contains("\"name\": \"h3\""),
"expected `name: h3` emitted for aliased entry; got:\n{body}"
);
assert!(
body.contains("\"resolved\": \"https://registry.npmjs.org/h3/-/h3-2.0.1-rc.20.tgz\""),
"expected `resolved:` URL emitted for aliased entry; got:\n{body}"
);
let reparsed = parse(out.path()).unwrap();
let pkg = &reparsed.packages["h3-v2@2.0.1-rc.20"];
assert_eq!(pkg.alias_of.as_deref(), Some("h3"));
assert_eq!(pkg.registry_name(), "h3");
}
#[test]
fn test_parse_peer_dependencies() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "peer-test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "peer-test",
"version": "1.0.0",
"dependencies": { "devtools-vite": "0.6.0", "vite": "8.0.0" }
},
"node_modules/devtools-vite": {
"version": "0.6.0",
"integrity": "sha512-a",
"peerDependencies": {
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"vite": { "optional": false }
}
},
"node_modules/vite": {
"version": "8.0.0",
"integrity": "sha512-b"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let devtools = &graph.packages["devtools-vite@0.6.0"];
assert_eq!(
devtools.peer_dependencies.get("vite").map(String::as_str),
Some("^6.0.0 || ^7.0.0 || ^8.0.0")
);
assert_eq!(
devtools
.peer_dependencies_meta
.get("vite")
.map(|m| m.optional),
Some(false)
);
}
#[test]
fn test_parse_no_peer_fields_stays_empty() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "no-peers",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": { "name": "no-peers", "version": "1.0.0", "dependencies": { "foo": "1.0.0" } },
"node_modules/foo": { "version": "1.0.0", "integrity": "sha512-x" }
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let foo = &graph.packages["foo@1.0.0"];
assert!(foo.peer_dependencies.is_empty());
assert!(foo.peer_dependencies_meta.is_empty());
}
#[test]
fn test_write_roundtrip_peer_dependencies() {
let mut graph = LockfileGraph::default();
let mut peer_deps = BTreeMap::new();
peer_deps.insert("vite".to_string(), "^6.0.0 || ^7.0.0 || ^8.0.0".to_string());
let mut peer_deps_meta = BTreeMap::new();
peer_deps_meta.insert("vite".to_string(), crate::PeerDepMeta { optional: true });
graph.packages.insert(
"devtools-vite@0.6.0".to_string(),
LockedPackage {
name: "devtools-vite".to_string(),
version: "0.6.0".to_string(),
integrity: Some("sha512-a".to_string()),
dep_path: "devtools-vite@0.6.0".to_string(),
peer_dependencies: peer_deps,
peer_dependencies_meta: peer_deps_meta,
..Default::default()
},
);
graph.packages.insert(
"vite@8.0.0".to_string(),
LockedPackage {
name: "vite".to_string(),
version: "8.0.0".to_string(),
integrity: Some("sha512-b".to_string()),
dep_path: "vite@8.0.0".to_string(),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![
DirectDep {
name: "devtools-vite".to_string(),
dep_path: "devtools-vite@0.6.0".to_string(),
dep_type: DepType::Production,
specifier: None,
},
DirectDep {
name: "vite".to_string(),
dep_path: "vite@8.0.0".to_string(),
dep_type: DepType::Production,
specifier: None,
},
],
);
let manifest = test_manifest();
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let body = std::fs::read_to_string(out.path()).unwrap();
assert!(
body.contains("\"peerDependencies\""),
"expected peerDependencies block to round-trip; got:\n{body}"
);
assert!(
body.contains("\"peerDependenciesMeta\""),
"expected peerDependenciesMeta block to round-trip; got:\n{body}"
);
let reparsed = parse(out.path()).unwrap();
let devtools = &reparsed.packages["devtools-vite@0.6.0"];
assert_eq!(
devtools.peer_dependencies.get("vite").map(String::as_str),
Some("^6.0.0 || ^7.0.0 || ^8.0.0")
);
assert_eq!(
devtools
.peer_dependencies_meta
.get("vite")
.map(|m| m.optional),
Some(true),
"peerDependenciesMeta.optional must survive write → parse round-trip"
);
}
#[test]
fn test_parse_npm_workspace_importers() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "workspace-root",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "workspace-root",
"version": "1.0.0",
"workspaces": ["web"]
},
"node_modules/mise-versions-web": {
"resolved": "web",
"link": true
},
"web": {
"name": "mise-versions-web",
"version": "0.0.1",
"dependencies": { "astro": "^6.0.0" },
"devDependencies": { "vite": "^7.3.2" }
},
"web/node_modules/astro": {
"version": "6.2.1",
"integrity": "sha512-astro"
},
"web/node_modules/vite": {
"version": "7.3.2",
"integrity": "sha512-vite"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let root = graph.importers.get(".").expect("root importer");
assert_eq!(root.len(), 1);
assert_eq!(root[0].name, "mise-versions-web");
assert!(matches!(
graph.packages[&root[0].dep_path].local_source,
Some(LocalSource::Link(_))
));
let web = graph.importers.get("web").expect("web importer");
assert_eq!(web.len(), 2);
assert!(web.iter().any(|dep| {
dep.name == "astro"
&& dep.dep_type == DepType::Production
&& dep.specifier.as_deref() == Some("^6.0.0")
}));
assert!(web.iter().any(|dep| {
dep.name == "vite"
&& dep.dep_type == DepType::Dev
&& dep.specifier.as_deref() == Some("^7.3.2")
}));
}
#[test]
fn test_write_npm_workspace_importers() {
let mut graph = LockfileGraph::default();
let web_link = LocalSource::Link(PathBuf::from("web"));
let web_dep_path = web_link.dep_path("mise-versions-web");
graph.packages.insert(
web_dep_path.clone(),
LockedPackage {
name: "mise-versions-web".to_string(),
version: "0.0.1".to_string(),
dep_path: web_dep_path.clone(),
local_source: Some(web_link),
..Default::default()
},
);
graph.packages.insert(
"astro@6.2.1".to_string(),
LockedPackage {
name: "astro".to_string(),
version: "6.2.1".to_string(),
integrity: Some("sha512-astro".to_string()),
dep_path: "astro@6.2.1".to_string(),
..Default::default()
},
);
graph.packages.insert(
"vite@7.3.2".to_string(),
LockedPackage {
name: "vite".to_string(),
version: "7.3.2".to_string(),
integrity: Some("sha512-vite".to_string()),
dep_path: "vite@7.3.2".to_string(),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![DirectDep {
name: "mise-versions-web".to_string(),
dep_path: web_dep_path.clone(),
dep_type: DepType::Production,
specifier: None,
}],
);
graph.importers.insert(
"web".to_string(),
vec![
DirectDep {
name: "astro".to_string(),
dep_path: "astro@6.2.1".to_string(),
dep_type: DepType::Production,
specifier: Some("^6.0.0".to_string()),
},
DirectDep {
name: "vite".to_string(),
dep_path: "vite@7.3.2".to_string(),
dep_type: DepType::Dev,
specifier: Some("^7.3.2".to_string()),
},
],
);
let manifest = aube_manifest::PackageJson {
name: Some("workspace-root".to_string()),
version: Some("1.0.0".to_string()),
..Default::default()
};
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(out.path()).unwrap()).unwrap();
assert_eq!(
json["packages"]["node_modules/mise-versions-web"]["link"],
true
);
assert_eq!(
json["packages"]["node_modules/mise-versions-web"]["resolved"],
"web"
);
assert_eq!(json["packages"]["web"]["dependencies"]["astro"], "^6.0.0");
assert_eq!(json["packages"]["web"]["devDependencies"]["vite"], "^7.3.2");
assert_eq!(
json["packages"]["web/node_modules/astro"]["version"],
"6.2.1"
);
assert_eq!(
json["packages"]["web/node_modules/vite"]["version"],
"7.3.2"
);
let reparsed = parse(out.path()).unwrap();
assert!(reparsed.importers.contains_key("web"));
}
#[test]
fn test_write_npm_workspace_skips_root_hoisted_dups() {
let mut graph = LockfileGraph::default();
let web_link = LocalSource::Link(PathBuf::from("web"));
let web_dep_path = web_link.dep_path("workspace-web");
graph.packages.insert(
web_dep_path.clone(),
LockedPackage {
name: "workspace-web".to_string(),
version: "0.0.1".to_string(),
dep_path: web_dep_path.clone(),
local_source: Some(web_link),
..Default::default()
},
);
graph.packages.insert(
"astro@6.2.1".to_string(),
LockedPackage {
name: "astro".to_string(),
version: "6.2.1".to_string(),
integrity: Some("sha512-astro".to_string()),
dep_path: "astro@6.2.1".to_string(),
..Default::default()
},
);
graph.importers.insert(
".".to_string(),
vec![
DirectDep {
name: "astro".to_string(),
dep_path: "astro@6.2.1".to_string(),
dep_type: DepType::Production,
specifier: Some("^6.0.0".to_string()),
},
DirectDep {
name: "workspace-web".to_string(),
dep_path: web_dep_path.clone(),
dep_type: DepType::Production,
specifier: None,
},
],
);
graph.importers.insert(
"web".to_string(),
vec![DirectDep {
name: "astro".to_string(),
dep_path: "astro@6.2.1".to_string(),
dep_type: DepType::Production,
specifier: Some("^6.0.0".to_string()),
}],
);
let manifest = aube_manifest::PackageJson {
name: Some("workspace-root".to_string()),
version: Some("1.0.0".to_string()),
dependencies: [("astro".to_string(), "^6.0.0".to_string())]
.into_iter()
.collect(),
..Default::default()
};
let out = tempfile::NamedTempFile::new().unwrap();
write(out.path(), &graph, &manifest).unwrap();
let json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(out.path()).unwrap()).unwrap();
assert_eq!(json["packages"]["node_modules/astro"]["version"], "6.2.1");
assert!(
json["packages"].get("web/node_modules/astro").is_none(),
"redundant workspace-nested astro should not be emitted"
);
}
#[test]
fn test_write_byte_identical_to_native_npm() {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/npm-native.json");
let original = std::fs::read_to_string(&fixture)
.unwrap()
.replace("\r\n", "\n");
let graph = parse(&fixture).unwrap();
let manifest = aube_manifest::PackageJson {
name: Some("aube-lockfile-stability".to_string()),
version: Some("1.0.0".to_string()),
dependencies: [
("chalk".to_string(), "^4.1.2".to_string()),
("picocolors".to_string(), "^1.1.1".to_string()),
("semver".to_string(), "^7.6.3".to_string()),
]
.into_iter()
.collect(),
..Default::default()
};
let tmp = tempfile::NamedTempFile::new().unwrap();
write(tmp.path(), &graph, &manifest).unwrap();
let written = std::fs::read_to_string(tmp.path()).unwrap();
if written != original {
panic!(
"npm writer drifted from native npm output.\n\n--- expected ---\n{original}\n--- got ---\n{written}"
);
}
}
#[test]
fn test_parse_workspace_links() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "workspace-root",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "workspace-root",
"version": "1.0.0",
"dependencies": { "@scope/app": "file:packages/app" }
},
"node_modules/@scope/app": {
"resolved": "packages/app",
"link": true
},
"node_modules/chalk": {
"version": "5.4.1",
"integrity": "sha512-chalk"
},
"packages/app": {
"name": "@scope/app",
"version": "0.68.1",
"dependencies": {
"chalk": "^5.4.1"
}
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let dep_path = LocalSource::Link(PathBuf::from("packages/app")).dep_path("@scope/app");
let importer = &graph.importers["."];
assert_eq!(importer.len(), 1);
assert_eq!(importer[0].name, "@scope/app");
assert_eq!(importer[0].dep_path, dep_path);
assert!(matches!(importer[0].dep_type, DepType::Production));
assert!(importer[0].specifier.is_none());
let app = &graph.packages[&importer[0].dep_path];
assert_eq!(app.version, "0.68.1");
assert_eq!(
app.local_source,
Some(LocalSource::Link(PathBuf::from("packages/app")))
);
assert_eq!(
app.dependencies.get("chalk").map(String::as_str),
Some("5.4.1")
);
assert!(!graph.packages.contains_key("@scope/app@0.68.1"));
}
#[test]
fn test_parse_workspace_links_undeclared_in_root_deps() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "workspace-root",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "workspace-root",
"version": "1.0.0",
"workspaces": ["projects/element-ng", "projects/charts-ng"],
"dependencies": { "chalk": "^5.4.1" }
},
"node_modules/@siemens/element-ng": {
"resolved": "projects/element-ng",
"link": true
},
"node_modules/@siemens/charts-ng": {
"resolved": "projects/charts-ng",
"link": true
},
"node_modules/chalk": {
"version": "5.4.1",
"integrity": "sha512-chalk"
},
"projects/element-ng": {
"name": "@siemens/element-ng",
"version": "21.0.0"
},
"projects/charts-ng": {
"name": "@siemens/charts-ng",
"version": "21.0.0"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
let importer = &graph.importers["."];
let names: Vec<&str> = importer.iter().map(|d| d.name.as_str()).collect();
assert!(names.contains(&"chalk"));
assert!(
names.contains(&"@siemens/element-ng"),
"workspace package `@siemens/element-ng` should be a direct dep of root \
so the linker creates `node_modules/@siemens/element-ng`, even though \
the root manifest doesn't list it; got importer deps {names:?}"
);
assert!(
names.contains(&"@siemens/charts-ng"),
"workspace package `@siemens/charts-ng` should be a direct dep of root; \
got importer deps {names:?}"
);
let element_ng = importer
.iter()
.find(|d| d.name == "@siemens/element-ng")
.unwrap();
assert_eq!(
graph.packages[&element_ng.dep_path].local_source,
Some(LocalSource::Link(PathBuf::from("projects/element-ng")))
);
}
#[test]
fn test_parse_funding_all_shapes() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test",
"version": "1.0.0",
"dependencies": {
"string-funding": "1.0.0",
"object-funding": "1.0.0",
"array-funding": "1.0.0",
"mixed-array-funding": "1.0.0",
"no-funding": "1.0.0"
}
},
"node_modules/string-funding": {
"version": "1.0.0",
"integrity": "sha512-aaa",
"funding": "https://example.com/sponsor"
},
"node_modules/object-funding": {
"version": "1.0.0",
"integrity": "sha512-bbb",
"funding": { "type": "github", "url": "https://github.com/sponsors/foo" }
},
"node_modules/array-funding": {
"version": "1.0.0",
"integrity": "sha512-ccc",
"funding": [
{ "type": "github", "url": "https://github.com/sponsors/csstools" },
{ "type": "opencollective", "url": "https://opencollective.com/csstools" }
]
},
"node_modules/mixed-array-funding": {
"version": "1.0.0",
"integrity": "sha512-ddd",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{ "type": "github", "url": "https://github.com/sponsors/fb55" }
]
},
"node_modules/no-funding": {
"version": "1.0.0",
"integrity": "sha512-eee"
}
}
}"#;
std::fs::write(tmp.path(), content).unwrap();
let graph = parse(tmp.path()).unwrap();
assert_eq!(
graph.packages["string-funding@1.0.0"]
.funding_url
.as_deref(),
Some("https://example.com/sponsor"),
);
assert_eq!(
graph.packages["object-funding@1.0.0"]
.funding_url
.as_deref(),
Some("https://github.com/sponsors/foo"),
);
assert_eq!(
graph.packages["array-funding@1.0.0"].funding_url.as_deref(),
Some("https://github.com/sponsors/csstools"),
);
assert_eq!(
graph.packages["mixed-array-funding@1.0.0"]
.funding_url
.as_deref(),
Some("https://github.com/fb55/htmlparser2?sponsor=1"),
);
assert!(graph.packages["no-funding@1.0.0"].funding_url.is_none());
}
}